From 240483d72f74f6f380d4df861fe78fda255daf73 Mon Sep 17 00:00:00 2001 From: Amr Hassan Date: Fri, 30 Sep 2011 23:02:43 +0200 Subject: [PATCH 001/768] Closes Issue #71 --- .build | 2 +- MANIFEST.in | 6 ++++++ pylast.py | 2 -- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100755 MANIFEST.in diff --git a/.build b/.build index 9d60796..da2d398 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -11 \ No newline at end of file +14 \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..9e84b8c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include pylast.py +include setup.py +include README +include COPYING +include INSTALL +include .build diff --git a/pylast.py b/pylast.py index aac62d9..a4e0c0e 100644 --- a/pylast.py +++ b/pylast.py @@ -352,8 +352,6 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - In choosing the backend used for caching, it will try _SqliteCacheBackend first if - the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. From a6b1f50f2e08bad2789f3dcbb1cb3dba0865bce0 Mon Sep 17 00:00:00 2001 From: Lukas Lipka Date: Sat, 10 Mar 2012 14:24:59 +0100 Subject: [PATCH 002/768] Initial import. --- .build | 1 - COPYING | 51 - INSTALL | 4 - MANIFEST.in | 6 - README | 7 - pylast.py | 3812 --------------------------------------------------- setup.py | 32 - 7 files changed, 3913 deletions(-) delete mode 100644 .build delete mode 100644 COPYING delete mode 100644 INSTALL delete mode 100755 MANIFEST.in delete mode 100644 pylast.py delete mode 100755 setup.py diff --git a/.build b/.build deleted file mode 100644 index da2d398..0000000 --- a/.build +++ /dev/null @@ -1 +0,0 @@ -14 \ No newline at end of file diff --git a/COPYING b/COPYING deleted file mode 100644 index eec88ff..0000000 --- a/COPYING +++ /dev/null @@ -1,51 +0,0 @@ - Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - -You must give any other recipients of the Work or Derivative Works a copy of this License; and - -You must cause any modified files to carry prominent notices stating that You changed the files; and - -You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - -If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. -You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. diff --git a/INSTALL b/INSTALL deleted file mode 100644 index f664153..0000000 --- a/INSTALL +++ /dev/null @@ -1,4 +0,0 @@ -Installation Instructions -========================= - -Execute "python setup.py install" as a super user. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100755 index 9e84b8c..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include pylast.py -include setup.py -include README -include COPYING -include INSTALL -include .build diff --git a/README b/README index 64b1b7f..e69de29 100644 --- a/README +++ b/README @@ -1,7 +0,0 @@ -pylast ------- - -A python interface to Last.fm. Try using the pydoc utility for help -on usage. -For more info check out the project's home page at http://code.google.com/p/pylast/ -or the mailing list http://groups.google.com/group/pylast/ diff --git a/pylast.py b/pylast.py deleted file mode 100644 index a4e0c0e..0000000 --- a/pylast.py +++ /dev/null @@ -1,3812 +0,0 @@ -# -*- coding: utf-8 -*- -# -# pylast - A Python interface to Last.fm (and other API compatible social networks) -# -# Copyright 2008-2010 Amr Hassan -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# http://code.google.com/p/pylast/ - -__version__ = '0.5' -__author__ = 'Amr Hassan' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan" -__license__ = "apache2" -__email__ = 'amr.hassan@gmail.com' - -import hashlib -from xml.dom import minidom -import xml.dom -import time -import shelve -import tempfile -import sys -import collections -import warnings - -def _deprecation_warning(message): - warnings.warn(message, DeprecationWarning) - -if sys.version_info[0] == 3: - from http.client import HTTPConnection - import html.entities as htmlentitydefs - from urllib.parse import splithost as url_split_host - from urllib.parse import quote_plus as url_quote_plus - - unichr = chr - -elif sys.version_info[0] == 2: - from httplib import HTTPConnection - import htmlentitydefs - from urllib import splithost as url_split_host - from urllib import quote_plus as url_quote_plus - -STATUS_INVALID_SERVICE = 2 -STATUS_INVALID_METHOD = 3 -STATUS_AUTH_FAILED = 4 -STATUS_INVALID_FORMAT = 5 -STATUS_INVALID_PARAMS = 6 -STATUS_INVALID_RESOURCE = 7 -STATUS_TOKEN_ERROR = 8 -STATUS_INVALID_SK = 9 -STATUS_INVALID_API_KEY = 10 -STATUS_OFFLINE = 11 -STATUS_SUBSCRIBERS_ONLY = 12 -STATUS_INVALID_SIGNATURE = 13 -STATUS_TOKEN_UNAUTHORIZED = 14 -STATUS_TOKEN_EXPIRED = 15 - -EVENT_ATTENDING = '0' -EVENT_MAYBE_ATTENDING = '1' -EVENT_NOT_ATTENDING = '2' - -PERIOD_OVERALL = 'overall' -PERIOD_7DAYS = "7day" -PERIOD_3MONTHS = '3month' -PERIOD_6MONTHS = '6month' -PERIOD_12MONTHS = '12month' - -DOMAIN_ENGLISH = 0 -DOMAIN_GERMAN = 1 -DOMAIN_SPANISH = 2 -DOMAIN_FRENCH = 3 -DOMAIN_ITALIAN = 4 -DOMAIN_POLISH = 5 -DOMAIN_PORTUGUESE = 6 -DOMAIN_SWEDISH = 7 -DOMAIN_TURKISH = 8 -DOMAIN_RUSSIAN = 9 -DOMAIN_JAPANESE = 10 -DOMAIN_CHINESE = 11 - -COVER_SMALL = 0 -COVER_MEDIUM = 1 -COVER_LARGE = 2 -COVER_EXTRA_LARGE = 3 -COVER_MEGA = 4 - -IMAGES_ORDER_POPULARITY = "popularity" -IMAGES_ORDER_DATE = "dateadded" - - -USER_MALE = 'Male' -USER_FEMALE = 'Female' - -SCROBBLE_SOURCE_USER = "P" -SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST = "R" -SCROBBLE_SOURCE_PERSONALIZED_BROADCAST = "E" -SCROBBLE_SOURCE_LASTFM = "L" -SCROBBLE_SOURCE_UNKNOWN = "U" - -SCROBBLE_MODE_PLAYED = "" -SCROBBLE_MODE_LOVED = "L" -SCROBBLE_MODE_BANNED = "B" -SCROBBLE_MODE_SKIPPED = "S" - -class _Network(object): - """ - A music social network website that is Last.fm or one exposing a Last.fm compatible API - """ - - def __init__(self, name, homepage, ws_server, api_key, api_secret, session_key, submission_server, username, password_hash, - domain_names, urls): - """ - name: the name of the network - homepage: the homepage url - ws_server: the url of the webservices server - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - submission_server: the url of the server to which tracks are submitted (scrobbled) - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - domain_names: a dict mapping each DOMAIN_* value to a string domain name - urls: a dict mapping types to urls - - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. - - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - - You should use a preconfigured network object through a get_*_network(...) method instead of creating an object - of this class, unless you know what you're doing. - """ - - self.name = name - self.homepage = homepage - self.ws_server = ws_server - self.api_key = api_key - self.api_secret = api_secret - self.session_key = session_key - self.submission_server = submission_server - self.username = username - self.password_hash = password_hash - self.domain_names = domain_names - self.urls = urls - - self.cache_backend = None - self.proxy_enabled = False - self.proxy = None - self.last_call_time = 0 - - #generate a session_key if necessary - if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): - sk_gen = SessionKeyGenerator(self) - self.session_key = sk_gen.get_session_key(self.username, self.password_hash) - - """def __repr__(self): - attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", "session_key", "submission_server", - "username", "password_hash", "domain_names", "urls") - - text = "pylast._Network(%s)" - args = [] - for attr in attributes: - args.append("=".join((attr, repr(getattr(self, attr))))) - - return text % ", ".join(args) - """ - - def __str__(self): - return "The %s Network" %self.name - - def get_artist(self, artist_name): - """ - Return an Artist object - """ - - return Artist(artist_name, self) - - def get_track(self, artist, title): - """ - Return a Track object - """ - - return Track(artist, title, self) - - def get_album(self, artist, title): - """ - Return an Album object - """ - - return Album(artist, title, self) - - def get_authenticated_user(self): - """ - Returns the authenticated user - """ - - return AuthenticatedUser(self) - - def get_country(self, country_name): - """ - Returns a country object - """ - - return Country(country_name, self) - - def get_group(self, name): - """ - Returns a Group object - """ - - return Group(name, self) - - def get_user(self, username): - """ - Returns a user object - """ - - return User(username, self) - - def get_tag(self, name): - """ - Returns a tag object - """ - - return Tag(name, self) - - def get_scrobbler(self, client_id, client_version): - """ - Returns a Scrobbler object used for submitting tracks to the server - - Quote from http://www.last.fm/api/submissions: - ======== - Client identifiers are used to provide a centrally managed database of - the client versions, allowing clients to be banned if they are found to - be behaving undesirably. The client ID is associated with a version - number on the server, however these are only incremented if a client is - banned and do not have to reflect the version of the actual client application. - - During development, clients which have not been allocated an identifier should - use the identifier tst, with a version number of 1.0. Do not distribute code or - client implementations which use this test identifier. Do not use the identifiers - used by other clients. - ========= - - To obtain a new client identifier please contact: - * Last.fm: submissions@last.fm - * # TODO: list others - - ...and provide us with the name of your client and its homepage address. - """ - - _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") - - return Scrobbler(self, client_id, client_version) - - def _get_language_domain(self, domain_language): - """ - Returns the mapped domain name of the network to a DOMAIN_* value - """ - - if domain_language in self.domain_names: - return self.domain_names[domain_language] - - def _get_url(self, domain, type): - return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type]) - - def _get_ws_auth(self): - """ - Returns a (API_KEY, API_SECRET, SESSION_KEY) tuple. - """ - return (self.api_key, self.api_secret, self.session_key) - - def _delay_call(self): - """ - Makes sure that web service calls are at least a second apart - """ - - # delay time in seconds - DELAY_TIME = 1.0 - now = time.time() - - if (now - self.last_call_time) < DELAY_TIME: - time.sleep(1) - - self.last_call_time = now - - def create_new_playlist(self, title, description): - """ - Creates a playlist for the authenticated user and returns it - title: The title of the new playlist. - description: The description of the new playlist. - """ - - params = {} - params['title'] = title - params['description'] = description - - doc = _Request(self, 'playlist.create', params).execute(False) - - e_id = doc.getElementsByTagName("id")[0].firstChild.data - user = doc.getElementsByTagName('playlists')[0].getAttribute('user') - - return Playlist(user, e_id, self) - - def get_top_tags(self, limit=None): - """Returns a sequence of the most used tags as a sequence of TopItem objects.""" - - doc = _Request(self, "tag.getTopTags").execute(True) - seq = [] - for node in doc.getElementsByTagName("tag"): - tag = Tag(_extract(node, "name"), self) - weight = _number(_extract(node, "count")) - - seq.append(TopItem(tag, weight)) - - if limit: - seq = seq[:limit] - - return seq - - def enable_proxy(self, host, port): - """Enable a default web proxy""" - - self.proxy = [host, _number(port)] - self.proxy_enabled = True - - def disable_proxy(self): - """Disable using the web proxy""" - - self.proxy_enabled = False - - def is_proxy_enabled(self): - """Returns True if a web proxy is enabled.""" - - return self.proxy_enabled - - def _get_proxy(self): - """Returns proxy details.""" - - return self.proxy - - def enable_caching(self, file_path = None): - """Enables caching request-wide for all cachable calls. - - * file_path: A file path for the backend storage file. If - None set, a temp file would probably be created, according the backend. - """ - - if not file_path: - file_path = tempfile.mktemp(prefix="pylast_tmp_") - - self.cache_backend = _ShelfCacheBackend(file_path) - - def disable_caching(self): - """Disables all caching features.""" - - self.cache_backend = None - - def is_caching_enabled(self): - """Returns True if caching is enabled.""" - - return not (self.cache_backend == None) - - def _get_cache_backend(self): - - return self.cache_backend - - def search_for_album(self, album_name): - """Searches for an album by its name. Returns a AlbumSearch object. - Use get_next_page() to retreive sequences of results.""" - - return AlbumSearch(album_name, self) - - def search_for_artist(self, artist_name): - """Searches of an artist by its name. Returns a ArtistSearch object. - Use get_next_page() to retreive sequences of results.""" - - return ArtistSearch(artist_name, self) - - def search_for_tag(self, tag_name): - """Searches of a tag by its name. Returns a TagSearch object. - Use get_next_page() to retreive sequences of results.""" - - return TagSearch(tag_name, self) - - def search_for_track(self, artist_name, track_name): - """Searches of a track by its name and its artist. Set artist to an empty string if not available. - Returns a TrackSearch object. - Use get_next_page() to retreive sequences of results.""" - - return TrackSearch(artist_name, track_name, self) - - def search_for_venue(self, venue_name, country_name): - """Searches of a venue by its name and its country. Set country_name to an empty string if not available. - Returns a VenueSearch object. - Use get_next_page() to retreive sequences of results.""" - - return VenueSearch(venue_name, country_name, self) - - def get_track_by_mbid(self, mbid): - """Looks up a track by its MusicBrainz ID""" - - params = {"mbid": mbid} - - doc = _Request(self, "track.getInfo", params).execute(True) - - return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) - - def get_artist_by_mbid(self, mbid): - """Loooks up an artist by its MusicBrainz ID""" - - params = {"mbid": mbid} - - doc = _Request(self, "artist.getInfo", params).execute(True) - - return Artist(_extract(doc, "name"), self) - - def get_album_by_mbid(self, mbid): - """Looks up an album by its MusicBrainz ID""" - - params = {"mbid": mbid} - - doc = _Request(self, "album.getInfo", params).execute(True) - - return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - - def update_now_playing(self, artist, title, album = None, album_artist = None, - duration = None, track_number = None, mbid = None, context = None): - """ - Used to notify Last.fm that a user has started listening to a track. - - Parameters: - artist (Required) : The artist name - title (Required) : The track title - album (Optional) : The album name. - album_artist (Optional) : The album artist - if this differs from the track artist. - duration (Optional) : The length of the track in seconds. - track_number (Optional) : The track number of the track on the album. - mbid (Optional) : The MusicBrainz Track ID. - context (Optional) : Sub-client version (not public, only enabled for certain API keys) - """ - - params = {"track": title, "artist": artist} - - if album: params["album"] = album - if album_artist: params["albumArtist"] = album_artist - if context: params["context"] = context - if track_number: params["trackNumber"] = track_number - if mbid: params["mbid"] = mbid - if duration: params["duration"] = duration - - _Request(self, "track.updateNowPlaying", params).execute() - - def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, - duration = None, stream_id = None, context = None, mbid = None): - - """Used to add a track-play to a user's profile. - - Parameters: - artist (Required) : The artist name. - title (Required) : The track name. - timestamp (Required) : The time the track started playing, in UNIX timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). This must be in the UTC time zone. - album (Optional) : The album name. - album_artist (Optional) : The album artist - if this differs from the track artist. - context (Optional) : Sub-client version (not public, only enabled for certain API keys) - stream_id (Optional) : The stream id for this track received from the radio.getPlaylist service. - track_number (Optional) : The track number of the track on the album. - mbid (Optional) : The MusicBrainz Track ID. - duration (Optional) : The length of the track in seconds. - """ - - return self.scrobble_many(({"artist": artist, "title": title, "timestamp": timestamp, "album": album, "album_artist": album_artist, - "track_number": track_number, "duration": duration, "stream_id": stream_id, "context": context, "mbid": mbid},)) - - def scrobble_many(self, tracks): - """ - Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per - track containing the keyword arguments as if passed to the scrobble() method. - """ - - tracks_to_scrobble = tracks[:50] - if len(tracks) > 50: - remaining_tracks = tracks[50:] - else: - remaining_tracks = None - - params = {} - for i in range(len(tracks_to_scrobble)): - - params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"] - params["track[%d]" % i] = tracks_to_scrobble[i]["title"] - - additional_args = ("timestamp", "album", "album_artist", "context", "stream_id", "track_number", "mbid", "duration") - args_map_to = {"album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID"} # so friggin lazy - - for arg in additional_args: - - if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]: - if arg in args_map_to: - maps_to = args_map_to[arg] - else: - maps_to = arg - - params["%s[%d]" %(maps_to, i)] = tracks_to_scrobble[i][arg] - - - _Request(self, "track.scrobble", params).execute() - - if remaining_tracks: - self.scrobble_many(remaining_tracks) - -class LastFMNetwork(_Network): - - """A Last.fm network object - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. - - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - - Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: - http://www.last.fm/api/account - """ - - def __init__(self, api_key="", api_secret="", session_key="", username="", password_hash=""): - _Network.__init__(self, - name = "Last.fm", - homepage = "http://last.fm", - ws_server = ("ws.audioscrobbler.com", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://post.audioscrobbler.com:80/", - username = username, - password_hash = password_hash, - domain_names = { - DOMAIN_ENGLISH: 'www.last.fm', - DOMAIN_GERMAN: 'www.lastfm.de', - DOMAIN_SPANISH: 'www.lastfm.es', - DOMAIN_FRENCH: 'www.lastfm.fr', - DOMAIN_ITALIAN: 'www.lastfm.it', - DOMAIN_POLISH: 'www.lastfm.pl', - DOMAIN_PORTUGUESE: 'www.lastfm.com.br', - DOMAIN_SWEDISH: 'www.lastfm.se', - DOMAIN_TURKISH: 'www.lastfm.com.tr', - DOMAIN_RUSSIAN: 'www.lastfm.ru', - DOMAIN_JAPANESE: 'www.lastfm.jp', - DOMAIN_CHINESE: 'cn.last.fm', - }, - urls = { - "album": "music/%(artist)s/%(album)s", - "artist": "music/%(artist)s", - "event": "event/%(id)s", - "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", - "tag": "tag/%(name)s", - "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", - "user": "user/%(name)s", - } - ) - - def __repr__(self): - return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, - "'%s'" %self.username, "'%s'" %self.password_hash))) - - def __str__(self): - return "LastFM Network" - -def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): - """ - Returns a preconfigured _Network object for Last.fm - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. - - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - - Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: - http://www.last.fm/api/account - """ - - _deprecation_warning("Create a LastFMNetwork object instead") - - return LastFMNetwork(api_key, api_secret, session_key, username, password_hash) - -class LibreFMNetwork(_Network): - """ - A preconfigured _Network object for Libre.fm - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. - """ - - def __init__(self, api_key="", api_secret="", session_key = "", username = "", password_hash = ""): - - _Network.__init__(self, - name = "Libre.fm", - homepage = "http://alpha.dev.libre.fm", - ws_server = ("alpha.dev.libre.fm", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://turtle.libre.fm:80/", - username = username, - password_hash = password_hash, - domain_names = { - DOMAIN_ENGLISH: "alpha.dev.libre.fm", - DOMAIN_GERMAN: "alpha.dev.libre.fm", - DOMAIN_SPANISH: "alpha.dev.libre.fm", - DOMAIN_FRENCH: "alpha.dev.libre.fm", - DOMAIN_ITALIAN: "alpha.dev.libre.fm", - DOMAIN_POLISH: "alpha.dev.libre.fm", - DOMAIN_PORTUGUESE: "alpha.dev.libre.fm", - DOMAIN_SWEDISH: "alpha.dev.libre.fm", - DOMAIN_TURKISH: "alpha.dev.libre.fm", - DOMAIN_RUSSIAN: "alpha.dev.libre.fm", - DOMAIN_JAPANESE: "alpha.dev.libre.fm", - DOMAIN_CHINESE: "alpha.dev.libre.fm", - }, - urls = { - "album": "artist/%(artist)s/album/%(album)s", - "artist": "artist/%(artist)s", - "event": "event/%(id)s", - "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", - "tag": "tag/%(name)s", - "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", - "user": "user/%(name)s", - } - ) - - def __repr__(self): - return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, - "'%s'" %self.username, "'%s'" %self.password_hash))) - - def __str__(self): - return "Libre.fm Network" - -def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): - """ - Returns a preconfigured _Network object for Libre.fm - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. - """ - - _deprecation_warning("DeprecationWarning: Create a LibreFMNetwork object instead") - - return LibreFMNetwork(api_key, api_secret, session_key, username, password_hash) - -class _ShelfCacheBackend(object): - """Used as a backend for caching cacheable requests.""" - def __init__(self, file_path = None): - self.shelf = shelve.open(file_path) - - def get_xml(self, key): - return self.shelf[key] - - def set_xml(self, key, xml_string): - self.shelf[key] = xml_string - - def has_key(self, key): - return key in self.shelf.keys() - -class _Request(object): - """Representing an abstract web service operation.""" - - def __init__(self, network, method_name, params = {}): - - self.network = network - self.params = {} - - for key in params: - self.params[key] = _unicode(params[key]) - - (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() - - self.params["api_key"] = self.api_key - self.params["method"] = method_name - - if network.is_caching_enabled(): - self.cache = network._get_cache_backend() - - if self.session_key: - self.params["sk"] = self.session_key - self.sign_it() - - def sign_it(self): - """Sign this request.""" - - if not "api_sig" in self.params.keys(): - self.params['api_sig'] = self._get_signature() - - def _get_signature(self): - """Returns a 32-character hexadecimal md5 hash of the signature string.""" - - keys = list(self.params.keys()) - - keys.sort() - - string = "" - - for name in keys: - string += name - string += self.params[name] - - string += self.api_secret - - return md5(string) - - def _get_cache_key(self): - """The cache key is a string of concatenated sorted names and values.""" - - keys = list(self.params.keys()) - keys.sort() - - cache_key = str() - - for key in keys: - if key != "api_sig" and key != "api_key" and key != "sk": - cache_key += key + _string(self.params[key]) - - return hashlib.sha1(cache_key).hexdigest() - - def _get_cached_response(self): - """Returns a file object of the cached response.""" - - if not self._is_cached(): - response = self._download_response() - self.cache.set_xml(self._get_cache_key(), response) - - return self.cache.get_xml(self._get_cache_key()) - - def _is_cached(self): - """Returns True if the request is already in cache.""" - - return self.cache.has_key(self._get_cache_key()) - - def _download_response(self): - """Returns a response body string from the server.""" - - # Delay the call if necessary - #self.network._delay_call() # enable it if you want. - - data = [] - for name in self.params.keys(): - data.append('='.join((name, url_quote_plus(_string(self.params[name]))))) - data = '&'.join(data) - - headers = { - "Content-type": "application/x-www-form-urlencoded", - 'Accept-Charset': 'utf-8', - 'User-Agent': "pylast" + '/' + __version__ - } - - (HOST_NAME, HOST_SUBDIR) = self.network.ws_server - - if self.network.is_proxy_enabled(): - conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) - - try: - conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, - body=data, headers=headers) - except Exception as e: - raise NetworkError(self.network, e) - - else: - conn = HTTPConnection(host=HOST_NAME) - - try: - conn.request(method='POST', url=HOST_SUBDIR, body=data, headers=headers) - except Exception as e: - raise NetworkError(self.network, e) - - try: - response_text = _unicode(conn.getresponse().read()) - except Exception as e: - raise MalformedResponseError(self.network, e) - - self._check_response_for_errors(response_text) - return response_text - - def execute(self, cacheable = False): - """Returns the XML DOM response of the POST Request from the server""" - - if self.network.is_caching_enabled() and cacheable: - response = self._get_cached_response() - else: - response = self._download_response() - - return minidom.parseString(_string(response)) - - def _check_response_for_errors(self, response): - """Checks the response for errors and raises one if any exists.""" - - try: - doc = minidom.parseString(_string(response)) - except Exception as e: - raise MalformedResponseError(self.network, e) - - e = doc.getElementsByTagName('lfm')[0] - - if e.getAttribute('status') != "ok": - e = doc.getElementsByTagName('error')[0] - status = e.getAttribute('code') - details = e.firstChild.data.strip() - raise WSError(self.network, status, details) - -class SessionKeyGenerator(object): - """Methods of generating a session key: - 1) Web Authentication: - a. network = get_*_network(API_KEY, API_SECRET) - b. sg = SessionKeyGenerator(network) - c. url = sg.get_web_auth_url() - d. Ask the user to open the url and authorize you, and wait for it. - e. session_key = sg.get_web_auth_session_key(url) - 2) Username and Password Authentication: - a. network = get_*_network(API_KEY, API_SECRET) - b. username = raw_input("Please enter your username: ") - c. password_hash = pylast.md5(raw_input("Please enter your password: ") - d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) - - A session key's lifetime is infinie, unless the user provokes the rights of the given API Key. - - If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a - SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this - manually, unless you want to. - """ - - def __init__(self, network): - self.network = network - self.web_auth_tokens = {} - - def _get_web_auth_token(self): - """Retrieves a token from the network for web authentication. - The token then has to be authorized from getAuthURL before creating session. - """ - - request = _Request(self.network, 'auth.getToken') - - # default action is that a request is signed only when - # a session key is provided. - request.sign_it() - - doc = request.execute() - - e = doc.getElementsByTagName('token')[0] - return e.firstChild.data - - def get_web_auth_url(self): - """The user must open this page, and you first, then call get_web_auth_session_key(url) after that.""" - - token = self._get_web_auth_token() - - url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ - {"homepage": self.network.homepage, "api": self.network.api_key, "token": token} - - self.web_auth_tokens[url] = token - - return url - - def get_web_auth_session_key(self, url): - """Retrieves the session key of a web authorization process by its url.""" - - if url in self.web_auth_tokens.keys(): - token = self.web_auth_tokens[url] - else: - token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed. - - request = _Request(self.network, 'auth.getSession', {'token': token}) - - # default action is that a request is signed only when - # a session key is provided. - request.sign_it() - - doc = request.execute() - - return doc.getElementsByTagName('key')[0].firstChild.data - - def get_session_key(self, username, password_hash): - """Retrieve a session key with a username and a md5 hash of the user's password.""" - - params = {"username": username, "authToken": md5(username + password_hash)} - request = _Request(self.network, "auth.getMobileSession", params) - - # default action is that a request is signed only when - # a session key is provided. - request.sign_it() - - doc = request.execute() - - return _extract(doc, "key") - -TopItem = collections.namedtuple("TopItem", ["item", "weight"]) -SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) -LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) -PlayedTrack = collections.namedtuple("PlayedTrack", ["track", "playback_date", "timestamp"]) -LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) -ImageSizes = collections.namedtuple("ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"]) -Image = collections.namedtuple("Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"]) -Shout = collections.namedtuple("Shout", ["body", "author", "date"]) - -def _string_output(funct): - def r(*args): - return _string(funct(*args)) - - return r - -def _pad_list(given_list, desired_length, padding = None): - """ - Pads a list to be of the desired_length. - """ - - while len(given_list) < desired_length: - given_list.append(padding) - - return given_list - -class _BaseObject(object): - """An abstract webservices object.""" - - network = None - - def __init__(self, network): - self.network = network - - def _request(self, method_name, cacheable = False, params = None): - if not params: - params = self._get_params() - - return _Request(self.network, method_name, params).execute(cacheable) - - def _get_params(self): - """Returns the most common set of parameters between all objects.""" - - return {} - - def __hash__(self): - return hash(self.network) + \ - hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(self._get_params().values())).lower()) - -class _Taggable(object): - """Common functions for classes with tags.""" - - def __init__(self, ws_prefix): - self.ws_prefix = ws_prefix - - def add_tags(self, tags): - """Adds one or several tags. - * tags: A sequence of tag names or Tag objects. - """ - - for tag in tags: - self.add_tag(tag) - - def add_tag(self, tag): - """Adds one tag. - * tag: a tag name or a Tag object. - """ - - if isinstance(tag, Tag): - tag = tag.get_name() - - params = self._get_params() - params['tags'] = tag - - self._request(self.ws_prefix + '.addTags', False, params) - - def remove_tag(self, tag): - """Remove a user's tag from this object.""" - - if isinstance(tag, Tag): - tag = tag.get_name() - - params = self._get_params() - params['tag'] = tag - - self._request(self.ws_prefix + '.removeTag', False, params) - - def get_tags(self): - """Returns a list of the tags set by the user to this object.""" - - # Uncacheable because it can be dynamically changed by the user. - params = self._get_params() - - doc = self._request(self.ws_prefix + '.getTags', False, params) - tag_names = _extract_all(doc, 'name') - tags = [] - for tag in tag_names: - tags.append(Tag(tag, self.network)) - - return tags - - def remove_tags(self, tags): - """Removes one or several tags from this object. - * tags: a sequence of tag names or Tag objects. - """ - - for tag in tags: - self.remove_tag(tag) - - def clear_tags(self): - """Clears all the user-set tags. """ - - self.remove_tags(*(self.get_tags())) - - def set_tags(self, tags): - """Sets this object's tags to only those tags. - * tags: a sequence of tag names or Tag objects. - """ - - c_old_tags = [] - old_tags = [] - c_new_tags = [] - new_tags = [] - - to_remove = [] - to_add = [] - - tags_on_server = self.get_tags() - - for tag in tags_on_server: - c_old_tags.append(tag.get_name().lower()) - old_tags.append(tag.get_name()) - - for tag in tags: - c_new_tags.append(tag.lower()) - new_tags.append(tag) - - for i in range(0, len(old_tags)): - if not c_old_tags[i] in c_new_tags: - to_remove.append(old_tags[i]) - - for i in range(0, len(new_tags)): - if not c_new_tags[i] in c_old_tags: - to_add.append(new_tags[i]) - - self.remove_tags(to_remove) - self.add_tags(to_add) - - def get_top_tags(self, limit=None): - """Returns a list of the most frequently used Tags on this object.""" - - doc = self._request(self.ws_prefix + '.getTopTags', True) - - elements = doc.getElementsByTagName('tag') - seq = [] - - for element in elements: - tag_name = _extract(element, 'name') - tagcount = _extract(element, 'count') - - seq.append(TopItem(Tag(tag_name, self.network), tagcount)) - - if limit: - seq = seq[:limit] - - return seq - -class WSError(Exception): - """Exception related to the Network web service""" - - def __init__(self, network, status, details): - self.status = status - self.details = details - self.network = network - - @_string_output - def __str__(self): - return self.details - - def get_id(self): - """Returns the exception ID, from one of the following: - STATUS_INVALID_SERVICE = 2 - STATUS_INVALID_METHOD = 3 - STATUS_AUTH_FAILED = 4 - STATUS_INVALID_FORMAT = 5 - STATUS_INVALID_PARAMS = 6 - STATUS_INVALID_RESOURCE = 7 - STATUS_TOKEN_ERROR = 8 - STATUS_INVALID_SK = 9 - STATUS_INVALID_API_KEY = 10 - STATUS_OFFLINE = 11 - STATUS_SUBSCRIBERS_ONLY = 12 - STATUS_TOKEN_UNAUTHORIZED = 14 - STATUS_TOKEN_EXPIRED = 15 - """ - - return self.status - -class MalformedResponseError(Exception): - """Exception conveying a malformed response from Last.fm.""" - - def __init__(self, network, underlying_error): - self.network = network - self.underlying_error = underlying_error - - def __str__(self): - return "Malformed response from Last.fm. Underlying error: %s" %str(self.underlying_error) - -class NetworkError(Exception): - """Exception conveying a problem in sending a request to Last.fm""" - - def __init__(self, network, underlying_error): - self.network = network - self.underlying_error = underlying_error - - def __str__(self): - return "NetworkError: %s" %str(self.underlying_error) - -class Album(_BaseObject, _Taggable): - """An album.""" - - title = None - artist = None - - def __init__(self, artist, title, network): - """ - Create an album instance. - # Parameters: - * artist: An artist name or an Artist object. - * title: The album title. - """ - - _BaseObject.__init__(self, network) - _Taggable.__init__(self, 'album') - - if isinstance(artist, Artist): - self.artist = artist - else: - self.artist = Artist(artist, self.network) - - self.title = title - - def __repr__(self): - return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) - - @_string_output - def __str__(self): - return _unicode("%s - %s") %(self.get_artist().get_name(), self.get_title()) - - def __eq__(self, other): - return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) - - def __ne__(self, other): - return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) - - def _get_params(self): - return {'artist': self.get_artist().get_name(), 'album': self.get_title(), } - - def get_artist(self): - """Returns the associated Artist object.""" - - return self.artist - - def get_title(self): - """Returns the album title.""" - - return self.title - - def get_name(self): - """Returns the album title (alias to Album.get_title).""" - - return self.get_title() - - def get_release_date(self): - """Retruns the release date of the album.""" - - return _extract(self._request("album.getInfo", cacheable = True), "releasedate") - - def get_cover_image(self, size = COVER_EXTRA_LARGE): - """ - Returns a uri to the cover image - size can be one of: - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - return _extract_all(self._request("album.getInfo", cacheable = True), 'image')[size] - - def get_id(self): - """Returns the ID""" - - return _extract(self._request("album.getInfo", cacheable = True), "id") - - def get_playcount(self): - """Returns the number of plays on the network""" - - return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) - - def get_listener_count(self): - """Returns the number of liteners on the network""" - - return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) - - def get_top_tags(self, limit=None): - """Returns a list of the most-applied tags to this album.""" - - doc = self._request("album.getInfo", True) - e = doc.getElementsByTagName("toptags")[0] - - seq = [] - for name in _extract_all(e, "name"): - seq.append(Tag(name, self.network)) - - if limit: - seq = seq[:limit] - - return seq - - def get_tracks(self): - """Returns the list of Tracks on this album.""" - - uri = 'lastfm://playlist/album/%s' %self.get_id() - - return XSPF(uri, self.network).get_tracks() - - def get_mbid(self): - """Returns the MusicBrainz id of the album.""" - - return _extract(self._request("album.getInfo", cacheable = True), "mbid") - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the album page on the network. - # Parameters: - * domain_name str: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - artist = _url_safe(self.get_artist().get_name()) - album = _url_safe(self.get_title()) - - return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album} - - def get_wiki_published_date(self): - """Returns the date of publishing this version of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "published") - - def get_wiki_summary(self): - """Returns the summary of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "summary") - - def get_wiki_content(self): - """Returns the content of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "content") - -class Artist(_BaseObject, _Taggable): - """An artist.""" - - name = None - - def __init__(self, name, network): - """Create an artist object. - # Parameters: - * name str: The artist's name. - """ - - _BaseObject.__init__(self, network) - _Taggable.__init__(self, 'artist') - - self.name = name - - def __repr__(self): - return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() - - def __ne__(self, other): - return self.get_name().lower() != other.get_name().lower() - - def _get_params(self): - return {'artist': self.get_name()} - - def get_name(self, properly_capitalized=False): - """Returns the name of the artist. - If properly_capitalized was asserted then the name would be downloaded - overwriting the given one.""" - - if properly_capitalized: - self.name = _extract(self._request("artist.getInfo", True), "name") - - return self.name - - def get_cover_image(self, size = COVER_MEGA): - """ - Returns a uri to the cover image - size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - return _extract_all(self._request("artist.getInfo", True), "image")[size] - - def get_playcount(self): - """Returns the number of plays on the network.""" - - return _number(_extract(self._request("artist.getInfo", True), "playcount")) - - def get_mbid(self): - """Returns the MusicBrainz ID of this artist.""" - - doc = self._request("artist.getInfo", True) - - return _extract(doc, "mbid") - - def get_listener_count(self): - """Returns the number of liteners on the network.""" - - if hasattr(self, "listener_count"): - return self.listener_count - else: - self.listener_count = _number(_extract(self._request("artist.getInfo", True), "listeners")) - return self.listener_count - - def is_streamable(self): - """Returns True if the artist is streamable.""" - - return bool(_number(_extract(self._request("artist.getInfo", True), "streamable"))) - - def get_bio_published_date(self): - """Returns the date on which the artist's biography was published.""" - - return _extract(self._request("artist.getInfo", True), "published") - - def get_bio_summary(self, language=None): - """Returns the summary of the artist's biography.""" - - if language: - params = self._get_params() - params["lang"] = language - else: - params = None - - return _extract(self._request("artist.getInfo", True, params), "summary") - - def get_bio_content(self, language=None): - """Returns the content of the artist's biography.""" - - if language: - params = self._get_params() - params["lang"] = language - else: - params = None - - return _extract(self._request("artist.getInfo", True, params), "content") - - def get_upcoming_events(self): - """Returns a list of the upcoming Events for this artist.""" - - doc = self._request('artist.getEvents', True) - - ids = _extract_all(doc, 'id') - - events = [] - for e_id in ids: - events.append(Event(e_id, self.network)) - - return events - - def get_similar(self, limit = None): - """Returns the similar artists on the network.""" - - params = self._get_params() - if limit: - params['limit'] = limit - - doc = self._request('artist.getSimilar', True, params) - - names = _extract_all(doc, "name") - matches = _extract_all(doc, "match") - - artists = [] - for i in range(0, len(names)): - artists.append(SimilarItem(Artist(names[i], self.network), _number(matches[i]))) - - return artists - - def get_top_albums(self): - """Retuns a list of the top albums.""" - - doc = self._request('artist.getTopAlbums', True) - - seq = [] - - for node in doc.getElementsByTagName("album"): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _extract(node, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq - - def get_top_tracks(self): - """Returns a list of the most played Tracks by this artist.""" - - doc = self._request("artist.getTopTracks", True) - - seq = [] - for track in doc.getElementsByTagName('track'): - - title = _extract(track, "name") - artist = _extract(track, "name", 1) - playcount = _number(_extract(track, "playcount")) - - seq.append( TopItem(Track(artist, title, self.network), playcount) ) - - return seq - - def get_top_fans(self, limit = None): - """Returns a list of the Users who played this artist the most. - # Parameters: - * limit int: Max elements. - """ - - doc = self._request('artist.getTopFans', True) - - seq = [] - - elements = doc.getElementsByTagName('user') - - for element in elements: - if limit and len(seq) >= limit: - break - - name = _extract(element, 'name') - weight = _number(_extract(element, 'weight')) - - seq.append(TopItem(User(name, self.network), weight)) - - return seq - - def share(self, users, message = None): - """Shares this artist (sends out recommendations). - # Parameters: - * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. - * message str: A message to include in the recommendation message. - """ - - #last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('artist.share', False, params) - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the artist page on the network. - # Parameters: - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - artist = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "artist") %{'artist': artist} - - def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): - """ - Returns a sequence of Image objects - if limit is None it will return all - order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE. - - If limit==None, it will try to pull all the available data. - """ - - images = [] - - params = self._get_params() - params["order"] = order - nodes = _collect_nodes(limit, self, "artist.getImages", True, params) - for e in nodes: - if _extract(e, "name"): - user = User(_extract(e, "name"), self.network) - else: - user = None - - images.append(Image( - _extract(e, "title"), - _extract(e, "url"), - _extract(e, "dateadded"), - _extract(e, "format"), - user, - ImageSizes(*_extract_all(e, "size")), - (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) - ) - ) - return images - - def get_shouts(self, limit=50): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "artist.getShouts", False): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request("artist.Shout", False, params) - - -class Event(_BaseObject): - """An event.""" - - id = None - - def __init__(self, event_id, network): - _BaseObject.__init__(self, network) - - self.id = event_id - - def __repr__(self): - return "pylast.Event(%s, %s)" %(repr(self.id), repr(self.network)) - - @_string_output - def __str__(self): - return "Event #" + self.get_id() - - def __eq__(self, other): - return self.get_id() == other.get_id() - - def __ne__(self, other): - return self.get_id() != other.get_id() - - def _get_params(self): - return {'event': self.get_id()} - - def attend(self, attending_status): - """Sets the attending status. - * attending_status: The attending status. Possible values: - o EVENT_ATTENDING - o EVENT_MAYBE_ATTENDING - o EVENT_NOT_ATTENDING - """ - - params = self._get_params() - params['status'] = attending_status - - self._request('event.attend', False, params) - - def get_attendees(self): - """ - Get a list of attendees for an event - """ - - doc = self._request("event.getAttendees", False) - - users = [] - for name in _extract_all(doc, "name"): - users.append(User(name, self.network)) - - return users - - def get_id(self): - """Returns the id of the event on the network. """ - - return self.id - - def get_title(self): - """Returns the title of the event. """ - - doc = self._request("event.getInfo", True) - - return _extract(doc, "title") - - def get_headliner(self): - """Returns the headliner of the event. """ - - doc = self._request("event.getInfo", True) - - return Artist(_extract(doc, "headliner"), self.network) - - def get_artists(self): - """Returns a list of the participating Artists. """ - - doc = self._request("event.getInfo", True) - names = _extract_all(doc, "artist") - - artists = [] - for name in names: - artists.append(Artist(name, self.network)) - - return artists - - def get_venue(self): - """Returns the venue where the event is held.""" - - doc = self._request("event.getInfo", True) - - v = doc.getElementsByTagName("venue")[0] - venue_id = _number(_extract(v, "id")) - - return Venue(venue_id, self.network) - - def get_start_date(self): - """Returns the date when the event starts.""" - - doc = self._request("event.getInfo", True) - - return _extract(doc, "startDate") - - def get_description(self): - """Returns the description of the event. """ - - doc = self._request("event.getInfo", True) - - return _extract(doc, "description") - - def get_cover_image(self, size = COVER_MEGA): - """ - Returns a uri to the cover image - size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - doc = self._request("event.getInfo", True) - - return _extract_all(doc, "image")[size] - - def get_attendance_count(self): - """Returns the number of attending people. """ - - doc = self._request("event.getInfo", True) - - return _number(_extract(doc, "attendance")) - - def get_review_count(self): - """Returns the number of available reviews for this event. """ - - doc = self._request("event.getInfo", True) - - return _number(_extract(doc, "reviews")) - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - return self.network._get_url(domain_name, "event") %{'id': self.get_id()} - - def share(self, users, message = None): - """Shares this event (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. - """ - - #last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('event.share', False, params) - - def get_shouts(self, limit=50): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "event.getShouts", False): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request("event.Shout", False, params) - -class Country(_BaseObject): - """A country at Last.fm.""" - - name = None - - def __init__(self, name, network): - _BaseObject.__init__(self, network) - - self.name = name - - def __repr__(self): - return "pylast.Country(%s, %s)" %(repr(self.name), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() - - def __ne__(self, other): - return self.get_name() != other.get_name() - - def _get_params(self): - return {'country': self.get_name()} - - def _get_name_from_code(self, alpha2code): - # TODO: Have this function lookup the alpha-2 code and return the country name. - - return alpha2code - - def get_name(self): - """Returns the country name. """ - - return self.name - - def get_top_artists(self): - """Returns a sequence of the most played artists.""" - - doc = self._request('geo.getTopArtists', True) - - seq = [] - for node in doc.getElementsByTagName("artist"): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") - - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq - - def get_top_tracks(self): - """Returns a sequence of the most played tracks""" - - doc = self._request("geo.getTopTracks", True) - - seq = [] - - for n in doc.getElementsByTagName('track'): - - title = _extract(n, 'name') - artist = _extract(n, 'name', 1) - playcount = _number(_extract(n, "playcount")) - - seq.append( TopItem(Track(artist, title, self.network), playcount)) - - return seq - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - country_name = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "country") %{'country_name': country_name} - - -class Library(_BaseObject): - """A user's Last.fm library.""" - - user = None - - def __init__(self, user, network): - _BaseObject.__init__(self, network) - - if isinstance(user, User): - self.user = user - else: - self.user = User(user, self.network) - - self._albums_index = 0 - self._artists_index = 0 - self._tracks_index = 0 - - def __repr__(self): - return "pylast.Library(%s, %s)" %(repr(self.user), repr(self.network)) - - @_string_output - def __str__(self): - return repr(self.get_user()) + "'s Library" - - def _get_params(self): - return {'user': self.user.get_name()} - - def get_user(self): - """Returns the user who owns this library.""" - - return self.user - - def add_album(self, album): - """Add an album to this library.""" - - params = self._get_params() - params["artist"] = album.get_artist.get_name() - params["album"] = album.get_name() - - self._request("library.addAlbum", False, params) - - def add_artist(self, artist): - """Add an artist to this library.""" - - params = self._get_params() - params["artist"] = artist.get_name() - - self._request("library.addArtist", False, params) - - def add_track(self, track): - """Add a track to this library.""" - - params = self._get_params() - params["track"] = track.get_title() - - self._request("library.addTrack", False, params) - - def get_albums(self, artist=None, limit=50): - """ - Returns a sequence of Album objects - If no artist is specified, it will return all, sorted by playcount descendingly. - If limit==None it will return all (may take a while) - """ - - params = self._get_params() - if artist: - params["artist"] = artist - - seq = [] - for node in _collect_nodes(limit, self, "library.getAlbums", True, params): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) - - seq.append(LibraryItem(Album(artist, name, self.network), playcount, tagcount)) - - return seq - - def get_artists(self, limit=50): - """ - Returns a sequence of Album objects - if limit==None it will return all (may take a while) - """ - - seq = [] - for node in _collect_nodes(limit, self, "library.getArtists", True): - name = _extract(node, "name") - - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) - - seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) - - return seq - - def get_tracks(self, artist=None, album=None, limit=50): - """ - Returns a sequence of Album objects - If limit==None it will return all (may take a while) - """ - - params = self._get_params() - if artist: - params["artist"] = artist - if album: - params["album"] = album - - seq = [] - for node in _collect_nodes(limit, self, "library.getTracks", True, params): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) - - seq.append(LibraryItem(Track(artist, name, self.network), playcount, tagcount)) - - return seq - - -class Playlist(_BaseObject): - """A Last.fm user playlist.""" - - id = None - user = None - - def __init__(self, user, id, network): - _BaseObject.__init__(self, network) - - if isinstance(user, User): - self.user = user - else: - self.user = User(user, self.network) - - self.id = id - - @_string_output - def __str__(self): - return repr(self.user) + "'s playlist # " + repr(self.id) - - def _get_info_node(self): - """Returns the node from user.getPlaylists where this playlist's info is.""" - - doc = self._request("user.getPlaylists", True) - - for node in doc.getElementsByTagName("playlist"): - if _extract(node, "id") == str(self.get_id()): - return node - - def _get_params(self): - return {'user': self.user.get_name(), 'playlistID': self.get_id()} - - def get_id(self): - """Returns the playlist id.""" - - return self.id - - def get_user(self): - """Returns the owner user of this playlist.""" - - return self.user - - def get_tracks(self): - """Returns a list of the tracks on this user playlist.""" - - uri = _unicode('lastfm://playlist/%s') %self.get_id() - - return XSPF(uri, self.network).get_tracks() - - def add_track(self, track): - """Adds a Track to this Playlist.""" - - params = self._get_params() - params['artist'] = track.get_artist().get_name() - params['track'] = track.get_title() - - self._request('playlist.addTrack', False, params) - - def get_title(self): - """Returns the title of this playlist.""" - - return _extract(self._get_info_node(), "title") - - def get_creation_date(self): - """Returns the creation date of this playlist.""" - - return _extract(self._get_info_node(), "date") - - def get_size(self): - """Returns the number of tracks in this playlist.""" - - return _number(_extract(self._get_info_node(), "size")) - - def get_description(self): - """Returns the description of this playlist.""" - - return _extract(self._get_info_node(), "description") - - def get_duration(self): - """Returns the duration of this playlist in milliseconds.""" - - return _number(_extract(self._get_info_node(), "duration")) - - def is_streamable(self): - """Returns True if the playlist is streamable. - For a playlist to be streamable, it needs at least 45 tracks by 15 different artists.""" - - if _extract(self._get_info_node(), "streamable") == '1': - return True - else: - return False - - def has_track(self, track): - """Checks to see if track is already in the playlist. - * track: Any Track object. - """ - - return track in self.get_tracks() - - def get_cover_image(self, size = COVER_EXTRA_LARGE): - """ - Returns a uri to the cover image - size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - return _extract(self._get_info_node(), "image")[size] - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the playlist on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - english_url = _extract(self._get_info_node(), "url") - appendix = english_url[english_url.rfind("/") + 1:] - - return self.network._get_url(domain_name, "playlist") %{'appendix': appendix, "user": self.get_user().get_name()} - - -class Tag(_BaseObject): - """A Last.fm object tag.""" - - # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it) - - name = None - - def __init__(self, name, network): - _BaseObject.__init__(self, network) - - self.name = name - - def __repr__(self): - return "pylast.Tag(%s, %s)" %(repr(self.name), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() - - def __ne__(self, other): - return self.get_name().lower() != other.get_name().lower() - - def _get_params(self): - return {'tag': self.get_name()} - - def get_name(self, properly_capitalized=False): - """Returns the name of the tag. """ - - if properly_capitalized: - self.name = _extract(self._request("tag.getInfo", True), "name") - - return self.name - - def get_similar(self): - """Returns the tags similar to this one, ordered by similarity. """ - - doc = self._request('tag.getSimilar', True) - - seq = [] - names = _extract_all(doc, 'name') - for name in names: - seq.append(Tag(name, self.network)) - - return seq - - def get_top_albums(self): - """Retuns a list of the top albums.""" - - doc = self._request('tag.getTopAlbums', True) - - seq = [] - - for node in doc.getElementsByTagName("album"): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _extract(node, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq - - def get_top_tracks(self): - """Returns a list of the most played Tracks by this artist.""" - - doc = self._request("tag.getTopTracks", True) - - seq = [] - for track in doc.getElementsByTagName('track'): - - title = _extract(track, "name") - artist = _extract(track, "name", 1) - playcount = _number(_extract(track, "playcount")) - - seq.append( TopItem(Track(artist, title, self.network), playcount) ) - - return seq - - def get_top_artists(self): - """Returns a sequence of the most played artists.""" - - doc = self._request('tag.getTopArtists', True) - - seq = [] - for node in doc.getElementsByTagName("artist"): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") - - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq - - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("tag.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - - return seq - - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("tag.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "weight")) - seq.append(TopItem(item, weight)) - - return seq - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the tag page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - name = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "tag") %{'name': name} - -class Track(_BaseObject, _Taggable): - """A Last.fm track.""" - - artist = None - title = None - - def __init__(self, artist, title, network): - _BaseObject.__init__(self, network) - _Taggable.__init__(self, 'track') - - if isinstance(artist, Artist): - self.artist = artist - else: - self.artist = Artist(artist, self.network) - - self.title = title - - def __repr__(self): - return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_artist().get_name() + ' - ' + self.get_title() - - def __eq__(self, other): - return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) - - def __ne__(self, other): - return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) - - def _get_params(self): - return {'artist': self.get_artist().get_name(), 'track': self.get_title()} - - def get_artist(self): - """Returns the associated Artist object.""" - - return self.artist - - def get_title(self, properly_capitalized=False): - """Returns the track title.""" - - if properly_capitalized: - self.title = _extract(self._request("track.getInfo", True), "name") - - return self.title - - def get_name(self, properly_capitalized=False): - """Returns the track title (alias to Track.get_title).""" - - return self.get_title(properly_capitalized) - - def get_id(self): - """Returns the track id on the network.""" - - doc = self._request("track.getInfo", True) - - return _extract(doc, "id") - - def get_duration(self): - """Returns the track duration.""" - - doc = self._request("track.getInfo", True) - - return _number(_extract(doc, "duration")) - - def get_mbid(self): - """Returns the MusicBrainz ID of this track.""" - - doc = self._request("track.getInfo", True) - - return _extract(doc, "mbid") - - def get_listener_count(self): - """Returns the listener count.""" - - if hasattr(self, "listener_count"): - return self.listener_count - else: - doc = self._request("track.getInfo", True) - self.listener_count = _number(_extract(doc, "listeners")) - return self.listener_count - - def get_playcount(self): - """Returns the play count.""" - - doc = self._request("track.getInfo", True) - return _number(_extract(doc, "playcount")) - - def is_streamable(self): - """Returns True if the track is available at Last.fm.""" - - doc = self._request("track.getInfo", True) - return _extract(doc, "streamable") == "1" - - def is_fulltrack_available(self): - """Returns True if the fulltrack is available for streaming.""" - - doc = self._request("track.getInfo", True) - return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" - - def get_album(self): - """Returns the album object of this track.""" - - doc = self._request("track.getInfo", True) - - albums = doc.getElementsByTagName("album") - - if len(albums) == 0: - return - - node = doc.getElementsByTagName("album")[0] - return Album(_extract(node, "artist"), _extract(node, "title"), self.network) - - def get_wiki_published_date(self): - """Returns the date of publishing this version of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "published") - - def get_wiki_summary(self): - """Returns the summary of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "summary") - - def get_wiki_content(self): - """Returns the content of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "content") - - def love(self): - """Adds the track to the user's loved tracks. """ - - self._request('track.love') - - def ban(self): - """Ban this track from ever playing on the radio. """ - - self._request('track.ban') - - def get_similar(self): - """Returns similar tracks for this track on the network, based on listening data. """ - - doc = self._request('track.getSimilar', True) - - seq = [] - for node in doc.getElementsByTagName("track"): - title = _extract(node, 'name') - artist = _extract(node, 'name', 1) - match = _number(_extract(node, "match")) - - seq.append(SimilarItem(Track(artist, title, self.network), match)) - - return seq - - def get_top_fans(self, limit = None): - """Returns a list of the Users who played this track.""" - - doc = self._request('track.getTopFans', True) - - seq = [] - - elements = doc.getElementsByTagName('user') - - for element in elements: - if limit and len(seq) >= limit: - break - - name = _extract(element, 'name') - weight = _number(_extract(element, 'weight')) - - seq.append(TopItem(User(name, self.network), weight)) - - return seq - - def share(self, users, message = None): - """Shares this track (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. - """ - - #last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('track.share', False, params) - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the track page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - artist = _url_safe(self.get_artist().get_name()) - title = _url_safe(self.get_title()) - - return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} - - def get_shouts(self, limit=50): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "track.getShouts", False): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - -class Group(_BaseObject): - """A Last.fm group.""" - - name = None - - def __init__(self, group_name, network): - _BaseObject.__init__(self, network) - - self.name = group_name - - def __repr__(self): - return "pylast.Group(%s, %s)" %(repr(self.name), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() - - def __ne__(self, other): - return self.get_name() != other.get_name() - - def _get_params(self): - return {'group': self.get_name()} - - def get_name(self): - """Returns the group name. """ - return self.name - - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("group.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - - return seq - - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_album_charts(self, from_date = None, to_date = None): - """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyAlbumChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("album"): - item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_track_charts(self, from_date = None, to_date = None): - """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyTrackChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("track"): - item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the group page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - name = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "group") %{'name': name} - - def get_members(self, limit=50): - """ - Returns a sequence of User objects - if limit==None it will return all - """ - - nodes = _collect_nodes(limit, self, "group.getMembers", False) - - users = [] - - for node in nodes: - users.append(User(_extract(node, "name"), self.network)) - - return users - -class XSPF(_BaseObject): - "A Last.fm XSPF playlist.""" - - uri = None - - def __init__(self, uri, network): - _BaseObject.__init__(self, network) - - self.uri = uri - - def _get_params(self): - return {'playlistURL': self.get_uri()} - - @_string_output - def __str__(self): - return self.get_uri() - - def __eq__(self, other): - return self.get_uri() == other.get_uri() - - def __ne__(self, other): - return self.get_uri() != other.get_uri() - - def get_uri(self): - """Returns the Last.fm playlist URI. """ - - return self.uri - - def get_tracks(self): - """Returns the tracks on this playlist.""" - - doc = self._request('playlist.fetch', True) - - seq = [] - for n in doc.getElementsByTagName('track'): - title = _extract(n, 'title') - artist = _extract(n, 'creator') - - seq.append(Track(artist, title, self.network)) - - return seq - -class User(_BaseObject): - """A Last.fm user.""" - - name = None - - def __init__(self, user_name, network): - _BaseObject.__init__(self, network) - - self.name = user_name - - self._past_events_index = 0 - self._recommended_events_index = 0 - self._recommended_artists_index = 0 - - def __repr__(self): - return "pylast.User(%s, %s)" %(repr(self.name), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, another): - return self.get_name() == another.get_name() - - def __ne__(self, another): - return self.get_name() != another.get_name() - - def _get_params(self): - return {"user": self.get_name()} - - def get_name(self, properly_capitalized=False): - """Returns the nuser name.""" - - if properly_capitalized: - self.name = _extract(self._request("user.getInfo", True), "name") - - return self.name - - def get_upcoming_events(self): - """Returns all the upcoming events for this user. """ - - doc = self._request('user.getEvents', True) - - ids = _extract_all(doc, 'id') - events = [] - - for e_id in ids: - events.append(Event(e_id, self.network)) - - return events - - def get_friends(self, limit = 50): - """Returns a list of the user's friends. """ - - seq = [] - for node in _collect_nodes(limit, self, "user.getFriends", False): - seq.append(User(_extract(node, "name"), self.network)) - - return seq - - def get_loved_tracks(self, limit=50): - """Returns this user's loved track as a sequence of LovedTrack objects - in reverse order of their timestamp, all the way back to the first track. - - If limit==None, it will try to pull all the available data. - - This method uses caching. Enable caching only if you're pulling a - large amount of data. - - Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ - - params = self._get_params() - if limit: - params['limit'] = limit - - seq = [] - for track in _collect_nodes(limit, self, "user.getLovedTracks", True, params): - - title = _extract(track, "name") - artist = _extract(track, "name", 1) - date = _extract(track, "date") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - - seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) - - return seq - - def get_neighbours(self, limit = 50): - """Returns a list of the user's friends.""" - - params = self._get_params() - if limit: - params['limit'] = limit - - doc = self._request('user.getNeighbours', True, params) - - seq = [] - names = _extract_all(doc, 'name') - - for name in names: - seq.append(User(name, self.network)) - - return seq - - def get_past_events(self, limit=50): - """ - Returns a sequence of Event objects - if limit==None it will return all - """ - - seq = [] - for n in _collect_nodes(limit, self, "user.getPastEvents", False): - seq.append(Event(_extract(n, "id"), self.network)) - - return seq - - def get_playlists(self): - """Returns a list of Playlists that this user owns.""" - - doc = self._request("user.getPlaylists", True) - - playlists = [] - for playlist_id in _extract_all(doc, "id"): - playlists.append(Playlist(self.get_name(), playlist_id, self.network)) - - return playlists - - def get_now_playing(self): - """Returns the currently playing track, or None if nothing is playing. """ - - params = self._get_params() - params['limit'] = '1' - - doc = self._request('user.getRecentTracks', False, params) - - e = doc.getElementsByTagName('track')[0] - - if not e.hasAttribute('nowplaying'): - return None - - artist = _extract(e, 'artist') - title = _extract(e, 'name') - - return Track(artist, title, self.network) - - - def get_recent_tracks(self, limit = 10): - """Returns this user's played track as a sequence of PlayedTrack objects - in reverse order of their playtime, all the way back to the first track. - - If limit==None, it will try to pull all the available data. - - This method uses caching. Enable caching only if you're pulling a - large amount of data. - - Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ - - params = self._get_params() - if limit: - params['limit'] = limit - - seq = [] - for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): - - if track.hasAttribute('nowplaying'): - continue #to prevent the now playing track from sneaking in here - - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - - seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) - - return seq - - def get_id(self): - """Returns the user id.""" - - doc = self._request("user.getInfo", True) - - return _extract(doc, "id") - - def get_language(self): - """Returns the language code of the language used by the user.""" - - doc = self._request("user.getInfo", True) - - return _extract(doc, "lang") - - def get_country(self): - """Returns the name of the country of the user.""" - - doc = self._request("user.getInfo", True) - - return Country(_extract(doc, "country"), self.network) - - def get_age(self): - """Returns the user's age.""" - - doc = self._request("user.getInfo", True) - - return _number(_extract(doc, "age")) - - def get_gender(self): - """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" - - doc = self._request("user.getInfo", True) - - value = _extract(doc, "gender") - - if value == 'm': - return USER_MALE - elif value == 'f': - return USER_FEMALE - - return None - - def is_subscriber(self): - """Returns whether the user is a subscriber or not. True or False.""" - - doc = self._request("user.getInfo", True) - - return _extract(doc, "subscriber") == "1" - - def get_playcount(self): - """Returns the user's playcount so far.""" - - doc = self._request("user.getInfo", True) - - return _number(_extract(doc, "playcount")) - - def get_top_albums(self, period = PERIOD_OVERALL): - """Returns the top albums played by a user. - * period: The period of time. Possible values: - o PERIOD_OVERALL - o PERIOD_7DAYS - o PERIOD_3MONTHS - o PERIOD_6MONTHS - o PERIOD_12MONTHS - """ - - params = self._get_params() - params['period'] = period - - doc = self._request('user.getTopAlbums', True, params) - - seq = [] - for album in doc.getElementsByTagName('album'): - name = _extract(album, 'name') - artist = _extract(album, 'name', 1) - playcount = _extract(album, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq - - def get_top_artists(self, period = PERIOD_OVERALL): - """Returns the top artists played by a user. - * period: The period of time. Possible values: - o PERIOD_OVERALL - o PERIOD_7DAYS - o PERIOD_3MONTHS - o PERIOD_6MONTHS - o PERIOD_12MONTHS - """ - - params = self._get_params() - params['period'] = period - - doc = self._request('user.getTopArtists', True, params) - - seq = [] - for node in doc.getElementsByTagName('artist'): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") - - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq - - def get_top_tags(self, limit=None): - """Returns a sequence of the top tags used by this user with their counts as TopItem objects. - * limit: The limit of how many tags to return. - """ - - doc = self._request("user.getTopTags", True) - - seq = [] - for node in doc.getElementsByTagName("tag"): - seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) - - if limit: - seq = seq[:limit] - - return seq - - def get_top_tracks(self, period = PERIOD_OVERALL): - """Returns the top tracks played by a user. - * period: The period of time. Possible values: - o PERIOD_OVERALL - o PERIOD_7DAYS - o PERIOD_3MONTHS - o PERIOD_6MONTHS - o PERIOD_12MONTHS - """ - - params = self._get_params() - params['period'] = period - - doc = self._request('user.getTopTracks', True, params) - - seq = [] - for track in doc.getElementsByTagName('track'): - name = _extract(track, 'name') - artist = _extract(track, 'name', 1) - playcount = _extract(track, "playcount") - - seq.append(TopItem(Track(artist, name, self.network), playcount)) - - return seq - - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("user.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - - return seq - - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_album_charts(self, from_date = None, to_date = None): - """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyAlbumChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("album"): - item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_track_charts(self, from_date = None, to_date = None): - """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyTrackChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("track"): - item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def compare_with_user(self, user, shared_artists_limit = None): - """Compare this user with another Last.fm user. - Returns a sequence (tasteometer_score, (shared_artist1, shared_artist2, ...)) - user: A User object or a username string/unicode object. - """ - - if isinstance(user, User): - user = user.get_name() - - params = self._get_params() - if shared_artists_limit: - params['limit'] = shared_artists_limit - params['type1'] = 'user' - params['type2'] = 'user' - params['value1'] = self.get_name() - params['value2'] = user - - doc = self._request('tasteometer.compare', False, params) - - score = _extract(doc, 'score') - - artists = doc.getElementsByTagName('artists')[0] - shared_artists_names = _extract_all(artists, 'name') - - shared_artists_seq = [] - - for name in shared_artists_names: - shared_artists_seq.append(Artist(name, self.network)) - - return (score, shared_artists_seq) - - def get_image(self): - """Returns the user's avatar.""" - - doc = self._request("user.getInfo", True) - - return _extract(doc, "image") - - def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the user page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - name = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "user") %{'name': name} - - def get_library(self): - """Returns the associated Library object. """ - - return Library(self, self.network) - - def get_shouts(self, limit=50): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "user.getShouts", False): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request("user.Shout", False, params) - -class AuthenticatedUser(User): - def __init__(self, network): - User.__init__(self, "", network); - - def _get_params(self): - return {"user": self.get_name()} - - def get_name(self): - """Returns the name of the authenticated user.""" - - doc = self._request("user.getInfo", True, {"user": ""}) # hack - - self.name = _extract(doc, "name") - return self.name - - def get_recommended_events(self, limit=50): - """ - Returns a sequence of Event objects - if limit==None it will return all - """ - - seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq - - def get_recommended_artists(self, limit=50): - """ - Returns a sequence of Event objects - if limit==None it will return all - """ - - seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False): - seq.append(Artist(_extract(node, "name"), self.network)) - - return seq - -class _Search(_BaseObject): - """An abstract class. Use one of its derivatives.""" - - def __init__(self, ws_prefix, search_terms, network): - _BaseObject.__init__(self, network) - - self._ws_prefix = ws_prefix - self.search_terms = search_terms - - self._last_page_index = 0 - - def _get_params(self): - params = {} - - for key in self.search_terms.keys(): - params[key] = self.search_terms[key] - - return params - - def get_total_result_count(self): - """Returns the total count of all the results.""" - - doc = self._request(self._ws_prefix + ".search", True) - - return _extract(doc, "opensearch:totalResults") - - def _retreive_page(self, page_index): - """Returns the node of matches to be processed""" - - params = self._get_params() - params["page"] = str(page_index) - doc = self._request(self._ws_prefix + ".search", True, params) - - return doc.getElementsByTagName(self._ws_prefix + "matches")[0] - - def _retrieve_next_page(self): - self._last_page_index += 1 - return self._retreive_page(self._last_page_index) - -class AlbumSearch(_Search): - """Search for an album by name.""" - - def __init__(self, album_name, network): - - _Search.__init__(self, "album", {"album": album_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Album objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("album"): - seq.append(Album(_extract(node, "artist"), _extract(node, "name"), self.network)) - - return seq - -class ArtistSearch(_Search): - """Search for an artist by artist name.""" - - def __init__(self, artist_name, network): - _Search.__init__(self, "artist", {"artist": artist_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Artist objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("artist"): - artist = Artist(_extract(node, "name"), self.network) - artist.listener_count = _number(_extract(node, "listeners")) - seq.append(artist) - - return seq - -class TagSearch(_Search): - """Search for a tag by tag name.""" - - def __init__(self, tag_name, network): - - _Search.__init__(self, "tag", {"tag": tag_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Tag objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("tag"): - tag = Tag(_extract(node, "name"), self.network) - tag.tag_count = _number(_extract(node, "count")) - seq.append(tag) - - return seq - -class TrackSearch(_Search): - """Search for a track by track title. If you don't wanna narrow the results down - by specifying the artist name, set it to empty string.""" - - def __init__(self, artist_name, track_title, network): - - _Search.__init__(self, "track", {"track": track_title, "artist": artist_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Track objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("track"): - track = Track(_extract(node, "artist"), _extract(node, "name"), self.network) - track.listener_count = _number(_extract(node, "listeners")) - seq.append(track) - - return seq - -class VenueSearch(_Search): - """Search for a venue by its name. If you don't wanna narrow the results down - by specifying a country, set it to empty string.""" - - def __init__(self, venue_name, country_name, network): - - _Search.__init__(self, "venue", {"venue": venue_name, "country": country_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Track objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("venue"): - seq.append(Venue(_extract(node, "id"), self.network)) - - return seq - -class Venue(_BaseObject): - """A venue where events are held.""" - - # TODO: waiting for a venue.getInfo web service to use. - - id = None - - def __init__(self, id, network): - _BaseObject.__init__(self, network) - - self.id = _number(id) - - def __repr__(self): - return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) - - @_string_output - def __str__(self): - return "Venue #" + str(self.id) - - def __eq__(self, other): - return self.get_id() == other.get_id() - - def _get_params(self): - return {"venue": self.get_id()} - - def get_id(self): - """Returns the id of the venue.""" - - return self.id - - def get_upcoming_events(self): - """Returns the upcoming events in this venue.""" - - doc = self._request("venue.getEvents", True) - - seq = [] - for node in doc.getElementsByTagName("event"): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq - - def get_past_events(self): - """Returns the past events held in this venue.""" - - doc = self._request("venue.getEvents", True) - - seq = [] - for node in doc.getElementsByTagName("event"): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq - -def md5(text): - """Returns the md5 hash of a string.""" - - h = hashlib.md5() - h.update(_unicode(text).encode("utf-8")) - - return h.hexdigest() - -def _unicode(text): - if sys.version_info[0] == 3: - if type(text) in (bytes, bytearray): - return str(text, "utf-8") - elif type(text) == str: - return text - else: - return str(text) - - elif sys.version_info[0] ==2: - if type(text) in (str,): - return unicode(text, "utf-8") - elif type(text) == unicode: - return text - else: - return unicode(text) - -def _string(text): - """For Python2 routines that can only process str type.""" - - if sys.version_info[0] == 3: - if type(text) != str: - return str(text) - else: - return text - - elif sys.version_info[0] == 2: - if type(text) == str: - return text - - if type(text) == int: - return str(text) - - return text.encode("utf-8") - -def _collect_nodes(limit, sender, method_name, cacheable, params=None): - """ - Returns a sequqnce of dom.Node objects about as close to - limit as possible - """ - - if not params: - params = sender._get_params() - - nodes = [] - page = 1 - end_of_pages = False - - while not end_of_pages and (not limit or (limit and len(nodes) < limit)): - params["page"] = str(page) - doc = sender._request(method_name, cacheable, params) - - main = doc.documentElement.childNodes[1] - - if main.hasAttribute("totalPages"): - total_pages = _number(main.getAttribute("totalPages")) - elif main.hasAttribute("totalpages"): - total_pages = _number(main.getAttribute("totalpages")) - else: - raise Exception("No total pages attribute") - - for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: - nodes.append(node) - - if page >= total_pages: - end_of_pages = True - - page += 1 - - return nodes - -def _extract(node, name, index = 0): - """Extracts a value from the xml string""" - - nodes = node.getElementsByTagName(name) - - if len(nodes): - if nodes[index].firstChild: - return _unescape_htmlentity(nodes[index].firstChild.data.strip()) - else: - return None - -def _extract_all(node, name, limit_count = None): - """Extracts all the values from the xml string. returning a list.""" - - seq = [] - - for i in range(0, len(node.getElementsByTagName(name))): - if len(seq) == limit_count: - break - - seq.append(_extract(node, name, i)) - - return seq - -def _url_safe(text): - """Does all kinds of tricks on a text to make it safe to use in a url.""" - - return url_quote_plus(url_quote_plus(_string(text))).lower() - -def _number(string): - """ - Extracts an int from a string. Returns a 0 if None or an empty string was passed - """ - - if not string: - return 0 - elif string == "": - return 0 - else: - try: - return int(string) - except ValueError: - return float(string) - -def _unescape_htmlentity(string): - - #string = _unicode(string) - - mapping = htmlentitydefs.name2codepoint - for key in mapping: - string = string.replace("&%s;" %key, unichr(mapping[key])) - - return string - -def extract_items(topitems_or_libraryitems): - """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects.""" - - seq = [] - for i in topitems_or_libraryitems: - seq.append(i.item) - - return seq - -class ScrobblingError(Exception): - def __init__(self, message): - Exception.__init__(self) - self.message = message - - @_string_output - def __str__(self): - return self.message - -class BannedClientError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__(self, "This version of the client has been banned") - -class BadAuthenticationError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__(self, "Bad authentication token") - -class BadTimeError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__(self, "Time provided is not close enough to current time") - -class BadSessionError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__(self, "Bad session id, consider re-handshaking") - -class _ScrobblerRequest(object): - - def __init__(self, url, params, network, type="POST"): - - for key in params: - params[key] = str(params[key]) - - self.params = params - self.type = type - (self.hostname, self.subdir) = url_split_host(url[len("http:"):]) - self.network = network - - def execute(self): - """Returns a string response of this request.""" - - connection = HTTPConnection(self.hostname) - - data = [] - for name in self.params.keys(): - value = url_quote_plus(self.params[name]) - data.append('='.join((name, value))) - data = "&".join(data) - - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept-Charset": "utf-8", - "User-Agent": "pylast" + "/" + __version__, - "HOST": self.hostname - } - - if self.type == "GET": - connection.request("GET", self.subdir + "?" + data, headers = headers) - else: - connection.request("POST", self.subdir, data, headers) - response = _unicode(connection.getresponse().read()) - - self._check_response_for_errors(response) - - return response - - def _check_response_for_errors(self, response): - """When passed a string response it checks for erros, raising - any exceptions as necessary.""" - - lines = response.split("\n") - status_line = lines[0] - - if status_line == "OK": - return - elif status_line == "BANNED": - raise BannedClientError() - elif status_line == "BADAUTH": - raise BadAuthenticationError() - elif status_line == "BADTIME": - raise BadTimeError() - elif status_line == "BADSESSION": - raise BadSessionError() - elif status_line.startswith("FAILED "): - reason = status_line[status_line.find("FAILED ")+len("FAILED "):] - raise ScrobblingError(reason) - -class Scrobbler(object): - """A class for scrobbling tracks to Last.fm""" - - session_id = None - nowplaying_url = None - submissions_url = None - - def __init__(self, network, client_id, client_version): - self.client_id = client_id - self.client_version = client_version - self.username = network.username - self.password = network.password_hash - self.network = network - - def _do_handshake(self): - """Handshakes with the server""" - - timestamp = str(int(time.time())) - - if self.password and self.username: - token = md5(self.password + timestamp) - elif self.network.api_key and self.network.api_secret and self.network.session_key: - if not self.username: - self.username = self.network.get_authenticated_user().get_name() - token = md5(self.network.api_secret + timestamp) - - params = {"hs": "true", "p": "1.2.1", "c": self.client_id, - "v": self.client_version, "u": self.username, "t": timestamp, - "a": token} - - if self.network.session_key and self.network.api_key: - params["sk"] = self.network.session_key - params["api_key"] = self.network.api_key - - server = self.network.submission_server - response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n") - - self.session_id = response[1] - self.nowplaying_url = response[2] - self.submissions_url = response[3] - - def _get_session_id(self, new = False): - """Returns a handshake. If new is true, then it will be requested from the server - even if one was cached.""" - - if not self.session_id or new: - self._do_handshake() - - return self.session_id - - def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): - - _deprecation_warning("DeprecationWarning: Use Netowrk.update_now_playing(...) instead") - - params = {"s": self._get_session_id(), "a": artist, "t": title, - "b": album, "l": duration, "n": track_number, "m": mbid} - - try: - _ScrobblerRequest(self.nowplaying_url, params, self.network).execute() - except BadSessionError: - self._do_handshake() - self.report_now_playing(artist, title, album, duration, track_number, mbid) - - def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""): - """Scrobble a track. parameters: - artist: Artist name. - title: Track title. - time_started: UTC timestamp of when the track started playing. - source: The source of the track - SCROBBLE_SOURCE_USER: Chosen by the user (the most common value, unless you have a reason for choosing otherwise, use this). - SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1). - SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised recommendation except Last.fm (e.g. Pandora, Launchcast). - SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the 5-digit recommendation_key value must be set. - SCROBBLE_SOURCE_UNKNOWN: Source unknown. - mode: The submission mode - SCROBBLE_MODE_PLAYED: The track was played. - SCROBBLE_MODE_LOVED: The user manually loved the track (implies a listen) - SCROBBLE_MODE_SKIPPED: The track was skipped (Only if source was Last.fm) - SCROBBLE_MODE_BANNED: The track was banned (Only if source was Last.fm) - duration: Track duration in seconds. - album: The album name. - track_number: The track number on the album. - mbid: MusicBrainz ID. - """ - - _deprecation_warning("DeprecationWarning: Use Network.scrobble(...) instead") - - params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title), - "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration), - "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid} - - _ScrobblerRequest(self.submissions_url, params, self.network).execute() - - def scrobble_many(self, tracks): - """ - Scrobble several tracks at once. - - tracks: A sequence of a sequence of parameters for each trach. The order of parameters - is the same as if passed to the scrobble() method. - """ - - _deprecation_warning("DeprecationWarning: Use Network.scrobble_many(...) instead") - - remainder = [] - - if len(tracks) > 50: - remainder = tracks[50:] - tracks = tracks[:50] - - params = {"s": self._get_session_id()} - - i = 0 - for t in tracks: - _pad_list(t, 9, "") - params["a[%s]" % str(i)] = _string(t[0]) - params["t[%s]" % str(i)] = _string(t[1]) - params["i[%s]" % str(i)] = str(t[2]) - params["o[%s]" % str(i)] = t[3] - params["r[%s]" % str(i)] = t[4] - params["l[%s]" % str(i)] = str(t[5]) - params["b[%s]" % str(i)] = _string(t[6]) - params["n[%s]" % str(i)] = t[7] - params["m[%s]" % str(i)] = t[8] - - i += 1 - - _ScrobblerRequest(self.submissions_url, params, self.network).execute() - - if remainder: - self.scrobble_many(remainder) diff --git a/setup.py b/setup.py deleted file mode 100755 index 4964af9..0000000 --- a/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python - -from distutils.core import setup - -import os -def get_build(): - path = "./.build" - - if os.path.exists(path): - fp = open(path, "r") - build = eval(fp.read()) - if os.path.exists("./.increase_build"): - build += 1 - fp.close() - else: - build = 1 - - fp = open(path, "w") - fp.write(str(build)) - fp.close() - - return str(build) - -setup(name = "pylast", - version = "0.5." + get_build(), - author = "Amr Hassan ", - description = "A Python interface to Last.fm (and other API compatible social networks)", - author_email = "amr.hassan@gmail.com", - url = "http://code.google.com/p/pylast/", - py_modules = ("pylast",), - license = "Apache2" - ) From ad525bdd8b983bc058a5efe0d2599884cc451bd3 Mon Sep 17 00:00:00 2001 From: Lukas Lipka Date: Sat, 10 Mar 2012 14:49:00 +0100 Subject: [PATCH 003/768] Import pylast-0.5.11 --- .build | 1 + AUTHORS | 1 + COPYING | 51 + INSTALL | 4 + PKG-INFO | 10 + README | 7 + pylast.py | 3814 +++++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 32 + 8 files changed, 3920 insertions(+) create mode 100644 .build create mode 100644 AUTHORS create mode 100644 COPYING create mode 100644 INSTALL create mode 100644 PKG-INFO create mode 100644 pylast.py create mode 100644 setup.py diff --git a/.build b/.build new file mode 100644 index 0000000..9d60796 --- /dev/null +++ b/.build @@ -0,0 +1 @@ +11 \ No newline at end of file diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..f09ae8f --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +Amr Hassan diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..eec88ff --- /dev/null +++ b/COPYING @@ -0,0 +1,51 @@ + Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and + +You must cause any modified files to carry prominent notices stating that You changed the files; and + +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..f664153 --- /dev/null +++ b/INSTALL @@ -0,0 +1,4 @@ +Installation Instructions +========================= + +Execute "python setup.py install" as a super user. diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..e6dad55 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: pylast +Version: 0.5.11 +Summary: A Python interface to Last.fm (and other API compatible social networks) +Home-page: http://code.google.com/p/pylast/ +Author: Amr Hassan +Author-email: amr.hassan@gmail.com +License: Apache2 +Description: UNKNOWN +Platform: UNKNOWN diff --git a/README b/README index e69de29..64b1b7f 100644 --- a/README +++ b/README @@ -0,0 +1,7 @@ +pylast +------ + +A python interface to Last.fm. Try using the pydoc utility for help +on usage. +For more info check out the project's home page at http://code.google.com/p/pylast/ +or the mailing list http://groups.google.com/group/pylast/ diff --git a/pylast.py b/pylast.py new file mode 100644 index 0000000..aac62d9 --- /dev/null +++ b/pylast.py @@ -0,0 +1,3814 @@ +# -*- coding: utf-8 -*- +# +# pylast - A Python interface to Last.fm (and other API compatible social networks) +# +# Copyright 2008-2010 Amr Hassan +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# http://code.google.com/p/pylast/ + +__version__ = '0.5' +__author__ = 'Amr Hassan' +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan" +__license__ = "apache2" +__email__ = 'amr.hassan@gmail.com' + +import hashlib +from xml.dom import minidom +import xml.dom +import time +import shelve +import tempfile +import sys +import collections +import warnings + +def _deprecation_warning(message): + warnings.warn(message, DeprecationWarning) + +if sys.version_info[0] == 3: + from http.client import HTTPConnection + import html.entities as htmlentitydefs + from urllib.parse import splithost as url_split_host + from urllib.parse import quote_plus as url_quote_plus + + unichr = chr + +elif sys.version_info[0] == 2: + from httplib import HTTPConnection + import htmlentitydefs + from urllib import splithost as url_split_host + from urllib import quote_plus as url_quote_plus + +STATUS_INVALID_SERVICE = 2 +STATUS_INVALID_METHOD = 3 +STATUS_AUTH_FAILED = 4 +STATUS_INVALID_FORMAT = 5 +STATUS_INVALID_PARAMS = 6 +STATUS_INVALID_RESOURCE = 7 +STATUS_TOKEN_ERROR = 8 +STATUS_INVALID_SK = 9 +STATUS_INVALID_API_KEY = 10 +STATUS_OFFLINE = 11 +STATUS_SUBSCRIBERS_ONLY = 12 +STATUS_INVALID_SIGNATURE = 13 +STATUS_TOKEN_UNAUTHORIZED = 14 +STATUS_TOKEN_EXPIRED = 15 + +EVENT_ATTENDING = '0' +EVENT_MAYBE_ATTENDING = '1' +EVENT_NOT_ATTENDING = '2' + +PERIOD_OVERALL = 'overall' +PERIOD_7DAYS = "7day" +PERIOD_3MONTHS = '3month' +PERIOD_6MONTHS = '6month' +PERIOD_12MONTHS = '12month' + +DOMAIN_ENGLISH = 0 +DOMAIN_GERMAN = 1 +DOMAIN_SPANISH = 2 +DOMAIN_FRENCH = 3 +DOMAIN_ITALIAN = 4 +DOMAIN_POLISH = 5 +DOMAIN_PORTUGUESE = 6 +DOMAIN_SWEDISH = 7 +DOMAIN_TURKISH = 8 +DOMAIN_RUSSIAN = 9 +DOMAIN_JAPANESE = 10 +DOMAIN_CHINESE = 11 + +COVER_SMALL = 0 +COVER_MEDIUM = 1 +COVER_LARGE = 2 +COVER_EXTRA_LARGE = 3 +COVER_MEGA = 4 + +IMAGES_ORDER_POPULARITY = "popularity" +IMAGES_ORDER_DATE = "dateadded" + + +USER_MALE = 'Male' +USER_FEMALE = 'Female' + +SCROBBLE_SOURCE_USER = "P" +SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST = "R" +SCROBBLE_SOURCE_PERSONALIZED_BROADCAST = "E" +SCROBBLE_SOURCE_LASTFM = "L" +SCROBBLE_SOURCE_UNKNOWN = "U" + +SCROBBLE_MODE_PLAYED = "" +SCROBBLE_MODE_LOVED = "L" +SCROBBLE_MODE_BANNED = "B" +SCROBBLE_MODE_SKIPPED = "S" + +class _Network(object): + """ + A music social network website that is Last.fm or one exposing a Last.fm compatible API + """ + + def __init__(self, name, homepage, ws_server, api_key, api_secret, session_key, submission_server, username, password_hash, + domain_names, urls): + """ + name: the name of the network + homepage: the homepage url + ws_server: the url of the webservices server + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + submission_server: the url of the server to which tracks are submitted (scrobbled) + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is the user's password + domain_names: a dict mapping each DOMAIN_* value to a string domain name + urls: a dict mapping types to urls + + if username and password_hash were provided and not session_key, session_key will be + generated automatically when needed. + + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + + You should use a preconfigured network object through a get_*_network(...) method instead of creating an object + of this class, unless you know what you're doing. + """ + + self.name = name + self.homepage = homepage + self.ws_server = ws_server + self.api_key = api_key + self.api_secret = api_secret + self.session_key = session_key + self.submission_server = submission_server + self.username = username + self.password_hash = password_hash + self.domain_names = domain_names + self.urls = urls + + self.cache_backend = None + self.proxy_enabled = False + self.proxy = None + self.last_call_time = 0 + + #generate a session_key if necessary + if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): + sk_gen = SessionKeyGenerator(self) + self.session_key = sk_gen.get_session_key(self.username, self.password_hash) + + """def __repr__(self): + attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", "session_key", "submission_server", + "username", "password_hash", "domain_names", "urls") + + text = "pylast._Network(%s)" + args = [] + for attr in attributes: + args.append("=".join((attr, repr(getattr(self, attr))))) + + return text % ", ".join(args) + """ + + def __str__(self): + return "The %s Network" %self.name + + def get_artist(self, artist_name): + """ + Return an Artist object + """ + + return Artist(artist_name, self) + + def get_track(self, artist, title): + """ + Return a Track object + """ + + return Track(artist, title, self) + + def get_album(self, artist, title): + """ + Return an Album object + """ + + return Album(artist, title, self) + + def get_authenticated_user(self): + """ + Returns the authenticated user + """ + + return AuthenticatedUser(self) + + def get_country(self, country_name): + """ + Returns a country object + """ + + return Country(country_name, self) + + def get_group(self, name): + """ + Returns a Group object + """ + + return Group(name, self) + + def get_user(self, username): + """ + Returns a user object + """ + + return User(username, self) + + def get_tag(self, name): + """ + Returns a tag object + """ + + return Tag(name, self) + + def get_scrobbler(self, client_id, client_version): + """ + Returns a Scrobbler object used for submitting tracks to the server + + Quote from http://www.last.fm/api/submissions: + ======== + Client identifiers are used to provide a centrally managed database of + the client versions, allowing clients to be banned if they are found to + be behaving undesirably. The client ID is associated with a version + number on the server, however these are only incremented if a client is + banned and do not have to reflect the version of the actual client application. + + During development, clients which have not been allocated an identifier should + use the identifier tst, with a version number of 1.0. Do not distribute code or + client implementations which use this test identifier. Do not use the identifiers + used by other clients. + ========= + + To obtain a new client identifier please contact: + * Last.fm: submissions@last.fm + * # TODO: list others + + ...and provide us with the name of your client and its homepage address. + """ + + _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") + + return Scrobbler(self, client_id, client_version) + + def _get_language_domain(self, domain_language): + """ + Returns the mapped domain name of the network to a DOMAIN_* value + """ + + if domain_language in self.domain_names: + return self.domain_names[domain_language] + + def _get_url(self, domain, type): + return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type]) + + def _get_ws_auth(self): + """ + Returns a (API_KEY, API_SECRET, SESSION_KEY) tuple. + """ + return (self.api_key, self.api_secret, self.session_key) + + def _delay_call(self): + """ + Makes sure that web service calls are at least a second apart + """ + + # delay time in seconds + DELAY_TIME = 1.0 + now = time.time() + + if (now - self.last_call_time) < DELAY_TIME: + time.sleep(1) + + self.last_call_time = now + + def create_new_playlist(self, title, description): + """ + Creates a playlist for the authenticated user and returns it + title: The title of the new playlist. + description: The description of the new playlist. + """ + + params = {} + params['title'] = title + params['description'] = description + + doc = _Request(self, 'playlist.create', params).execute(False) + + e_id = doc.getElementsByTagName("id")[0].firstChild.data + user = doc.getElementsByTagName('playlists')[0].getAttribute('user') + + return Playlist(user, e_id, self) + + def get_top_tags(self, limit=None): + """Returns a sequence of the most used tags as a sequence of TopItem objects.""" + + doc = _Request(self, "tag.getTopTags").execute(True) + seq = [] + for node in doc.getElementsByTagName("tag"): + tag = Tag(_extract(node, "name"), self) + weight = _number(_extract(node, "count")) + + seq.append(TopItem(tag, weight)) + + if limit: + seq = seq[:limit] + + return seq + + def enable_proxy(self, host, port): + """Enable a default web proxy""" + + self.proxy = [host, _number(port)] + self.proxy_enabled = True + + def disable_proxy(self): + """Disable using the web proxy""" + + self.proxy_enabled = False + + def is_proxy_enabled(self): + """Returns True if a web proxy is enabled.""" + + return self.proxy_enabled + + def _get_proxy(self): + """Returns proxy details.""" + + return self.proxy + + def enable_caching(self, file_path = None): + """Enables caching request-wide for all cachable calls. + In choosing the backend used for caching, it will try _SqliteCacheBackend first if + the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. + + * file_path: A file path for the backend storage file. If + None set, a temp file would probably be created, according the backend. + """ + + if not file_path: + file_path = tempfile.mktemp(prefix="pylast_tmp_") + + self.cache_backend = _ShelfCacheBackend(file_path) + + def disable_caching(self): + """Disables all caching features.""" + + self.cache_backend = None + + def is_caching_enabled(self): + """Returns True if caching is enabled.""" + + return not (self.cache_backend == None) + + def _get_cache_backend(self): + + return self.cache_backend + + def search_for_album(self, album_name): + """Searches for an album by its name. Returns a AlbumSearch object. + Use get_next_page() to retreive sequences of results.""" + + return AlbumSearch(album_name, self) + + def search_for_artist(self, artist_name): + """Searches of an artist by its name. Returns a ArtistSearch object. + Use get_next_page() to retreive sequences of results.""" + + return ArtistSearch(artist_name, self) + + def search_for_tag(self, tag_name): + """Searches of a tag by its name. Returns a TagSearch object. + Use get_next_page() to retreive sequences of results.""" + + return TagSearch(tag_name, self) + + def search_for_track(self, artist_name, track_name): + """Searches of a track by its name and its artist. Set artist to an empty string if not available. + Returns a TrackSearch object. + Use get_next_page() to retreive sequences of results.""" + + return TrackSearch(artist_name, track_name, self) + + def search_for_venue(self, venue_name, country_name): + """Searches of a venue by its name and its country. Set country_name to an empty string if not available. + Returns a VenueSearch object. + Use get_next_page() to retreive sequences of results.""" + + return VenueSearch(venue_name, country_name, self) + + def get_track_by_mbid(self, mbid): + """Looks up a track by its MusicBrainz ID""" + + params = {"mbid": mbid} + + doc = _Request(self, "track.getInfo", params).execute(True) + + return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) + + def get_artist_by_mbid(self, mbid): + """Loooks up an artist by its MusicBrainz ID""" + + params = {"mbid": mbid} + + doc = _Request(self, "artist.getInfo", params).execute(True) + + return Artist(_extract(doc, "name"), self) + + def get_album_by_mbid(self, mbid): + """Looks up an album by its MusicBrainz ID""" + + params = {"mbid": mbid} + + doc = _Request(self, "album.getInfo", params).execute(True) + + return Album(_extract(doc, "artist"), _extract(doc, "name"), self) + + def update_now_playing(self, artist, title, album = None, album_artist = None, + duration = None, track_number = None, mbid = None, context = None): + """ + Used to notify Last.fm that a user has started listening to a track. + + Parameters: + artist (Required) : The artist name + title (Required) : The track title + album (Optional) : The album name. + album_artist (Optional) : The album artist - if this differs from the track artist. + duration (Optional) : The length of the track in seconds. + track_number (Optional) : The track number of the track on the album. + mbid (Optional) : The MusicBrainz Track ID. + context (Optional) : Sub-client version (not public, only enabled for certain API keys) + """ + + params = {"track": title, "artist": artist} + + if album: params["album"] = album + if album_artist: params["albumArtist"] = album_artist + if context: params["context"] = context + if track_number: params["trackNumber"] = track_number + if mbid: params["mbid"] = mbid + if duration: params["duration"] = duration + + _Request(self, "track.updateNowPlaying", params).execute() + + def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, + duration = None, stream_id = None, context = None, mbid = None): + + """Used to add a track-play to a user's profile. + + Parameters: + artist (Required) : The artist name. + title (Required) : The track name. + timestamp (Required) : The time the track started playing, in UNIX timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). This must be in the UTC time zone. + album (Optional) : The album name. + album_artist (Optional) : The album artist - if this differs from the track artist. + context (Optional) : Sub-client version (not public, only enabled for certain API keys) + stream_id (Optional) : The stream id for this track received from the radio.getPlaylist service. + track_number (Optional) : The track number of the track on the album. + mbid (Optional) : The MusicBrainz Track ID. + duration (Optional) : The length of the track in seconds. + """ + + return self.scrobble_many(({"artist": artist, "title": title, "timestamp": timestamp, "album": album, "album_artist": album_artist, + "track_number": track_number, "duration": duration, "stream_id": stream_id, "context": context, "mbid": mbid},)) + + def scrobble_many(self, tracks): + """ + Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per + track containing the keyword arguments as if passed to the scrobble() method. + """ + + tracks_to_scrobble = tracks[:50] + if len(tracks) > 50: + remaining_tracks = tracks[50:] + else: + remaining_tracks = None + + params = {} + for i in range(len(tracks_to_scrobble)): + + params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"] + params["track[%d]" % i] = tracks_to_scrobble[i]["title"] + + additional_args = ("timestamp", "album", "album_artist", "context", "stream_id", "track_number", "mbid", "duration") + args_map_to = {"album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID"} # so friggin lazy + + for arg in additional_args: + + if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]: + if arg in args_map_to: + maps_to = args_map_to[arg] + else: + maps_to = arg + + params["%s[%d]" %(maps_to, i)] = tracks_to_scrobble[i][arg] + + + _Request(self, "track.scrobble", params).execute() + + if remaining_tracks: + self.scrobble_many(remaining_tracks) + +class LastFMNetwork(_Network): + + """A Last.fm network object + + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is the user's password + + if username and password_hash were provided and not session_key, session_key will be + generated automatically when needed. + + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + + Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: + http://www.last.fm/api/account + """ + + def __init__(self, api_key="", api_secret="", session_key="", username="", password_hash=""): + _Network.__init__(self, + name = "Last.fm", + homepage = "http://last.fm", + ws_server = ("ws.audioscrobbler.com", "/2.0/"), + api_key = api_key, + api_secret = api_secret, + session_key = session_key, + submission_server = "http://post.audioscrobbler.com:80/", + username = username, + password_hash = password_hash, + domain_names = { + DOMAIN_ENGLISH: 'www.last.fm', + DOMAIN_GERMAN: 'www.lastfm.de', + DOMAIN_SPANISH: 'www.lastfm.es', + DOMAIN_FRENCH: 'www.lastfm.fr', + DOMAIN_ITALIAN: 'www.lastfm.it', + DOMAIN_POLISH: 'www.lastfm.pl', + DOMAIN_PORTUGUESE: 'www.lastfm.com.br', + DOMAIN_SWEDISH: 'www.lastfm.se', + DOMAIN_TURKISH: 'www.lastfm.com.tr', + DOMAIN_RUSSIAN: 'www.lastfm.ru', + DOMAIN_JAPANESE: 'www.lastfm.jp', + DOMAIN_CHINESE: 'cn.last.fm', + }, + urls = { + "album": "music/%(artist)s/%(album)s", + "artist": "music/%(artist)s", + "event": "event/%(id)s", + "country": "place/%(country_name)s", + "playlist": "user/%(user)s/library/playlists/%(appendix)s", + "tag": "tag/%(name)s", + "track": "music/%(artist)s/_/%(title)s", + "group": "group/%(name)s", + "user": "user/%(name)s", + } + ) + + def __repr__(self): + return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + "'%s'" %self.username, "'%s'" %self.password_hash))) + + def __str__(self): + return "LastFM Network" + +def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + """ + Returns a preconfigured _Network object for Last.fm + + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is the user's password + + if username and password_hash were provided and not session_key, session_key will be + generated automatically when needed. + + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + + Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: + http://www.last.fm/api/account + """ + + _deprecation_warning("Create a LastFMNetwork object instead") + + return LastFMNetwork(api_key, api_secret, session_key, username, password_hash) + +class LibreFMNetwork(_Network): + """ + A preconfigured _Network object for Libre.fm + + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is the user's password + + if username and password_hash were provided and not session_key, session_key will be + generated automatically when needed. + """ + + def __init__(self, api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + + _Network.__init__(self, + name = "Libre.fm", + homepage = "http://alpha.dev.libre.fm", + ws_server = ("alpha.dev.libre.fm", "/2.0/"), + api_key = api_key, + api_secret = api_secret, + session_key = session_key, + submission_server = "http://turtle.libre.fm:80/", + username = username, + password_hash = password_hash, + domain_names = { + DOMAIN_ENGLISH: "alpha.dev.libre.fm", + DOMAIN_GERMAN: "alpha.dev.libre.fm", + DOMAIN_SPANISH: "alpha.dev.libre.fm", + DOMAIN_FRENCH: "alpha.dev.libre.fm", + DOMAIN_ITALIAN: "alpha.dev.libre.fm", + DOMAIN_POLISH: "alpha.dev.libre.fm", + DOMAIN_PORTUGUESE: "alpha.dev.libre.fm", + DOMAIN_SWEDISH: "alpha.dev.libre.fm", + DOMAIN_TURKISH: "alpha.dev.libre.fm", + DOMAIN_RUSSIAN: "alpha.dev.libre.fm", + DOMAIN_JAPANESE: "alpha.dev.libre.fm", + DOMAIN_CHINESE: "alpha.dev.libre.fm", + }, + urls = { + "album": "artist/%(artist)s/album/%(album)s", + "artist": "artist/%(artist)s", + "event": "event/%(id)s", + "country": "place/%(country_name)s", + "playlist": "user/%(user)s/library/playlists/%(appendix)s", + "tag": "tag/%(name)s", + "track": "music/%(artist)s/_/%(title)s", + "group": "group/%(name)s", + "user": "user/%(name)s", + } + ) + + def __repr__(self): + return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + "'%s'" %self.username, "'%s'" %self.password_hash))) + + def __str__(self): + return "Libre.fm Network" + +def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + """ + Returns a preconfigured _Network object for Libre.fm + + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is the user's password + + if username and password_hash were provided and not session_key, session_key will be + generated automatically when needed. + """ + + _deprecation_warning("DeprecationWarning: Create a LibreFMNetwork object instead") + + return LibreFMNetwork(api_key, api_secret, session_key, username, password_hash) + +class _ShelfCacheBackend(object): + """Used as a backend for caching cacheable requests.""" + def __init__(self, file_path = None): + self.shelf = shelve.open(file_path) + + def get_xml(self, key): + return self.shelf[key] + + def set_xml(self, key, xml_string): + self.shelf[key] = xml_string + + def has_key(self, key): + return key in self.shelf.keys() + +class _Request(object): + """Representing an abstract web service operation.""" + + def __init__(self, network, method_name, params = {}): + + self.network = network + self.params = {} + + for key in params: + self.params[key] = _unicode(params[key]) + + (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() + + self.params["api_key"] = self.api_key + self.params["method"] = method_name + + if network.is_caching_enabled(): + self.cache = network._get_cache_backend() + + if self.session_key: + self.params["sk"] = self.session_key + self.sign_it() + + def sign_it(self): + """Sign this request.""" + + if not "api_sig" in self.params.keys(): + self.params['api_sig'] = self._get_signature() + + def _get_signature(self): + """Returns a 32-character hexadecimal md5 hash of the signature string.""" + + keys = list(self.params.keys()) + + keys.sort() + + string = "" + + for name in keys: + string += name + string += self.params[name] + + string += self.api_secret + + return md5(string) + + def _get_cache_key(self): + """The cache key is a string of concatenated sorted names and values.""" + + keys = list(self.params.keys()) + keys.sort() + + cache_key = str() + + for key in keys: + if key != "api_sig" and key != "api_key" and key != "sk": + cache_key += key + _string(self.params[key]) + + return hashlib.sha1(cache_key).hexdigest() + + def _get_cached_response(self): + """Returns a file object of the cached response.""" + + if not self._is_cached(): + response = self._download_response() + self.cache.set_xml(self._get_cache_key(), response) + + return self.cache.get_xml(self._get_cache_key()) + + def _is_cached(self): + """Returns True if the request is already in cache.""" + + return self.cache.has_key(self._get_cache_key()) + + def _download_response(self): + """Returns a response body string from the server.""" + + # Delay the call if necessary + #self.network._delay_call() # enable it if you want. + + data = [] + for name in self.params.keys(): + data.append('='.join((name, url_quote_plus(_string(self.params[name]))))) + data = '&'.join(data) + + headers = { + "Content-type": "application/x-www-form-urlencoded", + 'Accept-Charset': 'utf-8', + 'User-Agent': "pylast" + '/' + __version__ + } + + (HOST_NAME, HOST_SUBDIR) = self.network.ws_server + + if self.network.is_proxy_enabled(): + conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) + + try: + conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + body=data, headers=headers) + except Exception as e: + raise NetworkError(self.network, e) + + else: + conn = HTTPConnection(host=HOST_NAME) + + try: + conn.request(method='POST', url=HOST_SUBDIR, body=data, headers=headers) + except Exception as e: + raise NetworkError(self.network, e) + + try: + response_text = _unicode(conn.getresponse().read()) + except Exception as e: + raise MalformedResponseError(self.network, e) + + self._check_response_for_errors(response_text) + return response_text + + def execute(self, cacheable = False): + """Returns the XML DOM response of the POST Request from the server""" + + if self.network.is_caching_enabled() and cacheable: + response = self._get_cached_response() + else: + response = self._download_response() + + return minidom.parseString(_string(response)) + + def _check_response_for_errors(self, response): + """Checks the response for errors and raises one if any exists.""" + + try: + doc = minidom.parseString(_string(response)) + except Exception as e: + raise MalformedResponseError(self.network, e) + + e = doc.getElementsByTagName('lfm')[0] + + if e.getAttribute('status') != "ok": + e = doc.getElementsByTagName('error')[0] + status = e.getAttribute('code') + details = e.firstChild.data.strip() + raise WSError(self.network, status, details) + +class SessionKeyGenerator(object): + """Methods of generating a session key: + 1) Web Authentication: + a. network = get_*_network(API_KEY, API_SECRET) + b. sg = SessionKeyGenerator(network) + c. url = sg.get_web_auth_url() + d. Ask the user to open the url and authorize you, and wait for it. + e. session_key = sg.get_web_auth_session_key(url) + 2) Username and Password Authentication: + a. network = get_*_network(API_KEY, API_SECRET) + b. username = raw_input("Please enter your username: ") + c. password_hash = pylast.md5(raw_input("Please enter your password: ") + d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) + + A session key's lifetime is infinie, unless the user provokes the rights of the given API Key. + + If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a + SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this + manually, unless you want to. + """ + + def __init__(self, network): + self.network = network + self.web_auth_tokens = {} + + def _get_web_auth_token(self): + """Retrieves a token from the network for web authentication. + The token then has to be authorized from getAuthURL before creating session. + """ + + request = _Request(self.network, 'auth.getToken') + + # default action is that a request is signed only when + # a session key is provided. + request.sign_it() + + doc = request.execute() + + e = doc.getElementsByTagName('token')[0] + return e.firstChild.data + + def get_web_auth_url(self): + """The user must open this page, and you first, then call get_web_auth_session_key(url) after that.""" + + token = self._get_web_auth_token() + + url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ + {"homepage": self.network.homepage, "api": self.network.api_key, "token": token} + + self.web_auth_tokens[url] = token + + return url + + def get_web_auth_session_key(self, url): + """Retrieves the session key of a web authorization process by its url.""" + + if url in self.web_auth_tokens.keys(): + token = self.web_auth_tokens[url] + else: + token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed. + + request = _Request(self.network, 'auth.getSession', {'token': token}) + + # default action is that a request is signed only when + # a session key is provided. + request.sign_it() + + doc = request.execute() + + return doc.getElementsByTagName('key')[0].firstChild.data + + def get_session_key(self, username, password_hash): + """Retrieve a session key with a username and a md5 hash of the user's password.""" + + params = {"username": username, "authToken": md5(username + password_hash)} + request = _Request(self.network, "auth.getMobileSession", params) + + # default action is that a request is signed only when + # a session key is provided. + request.sign_it() + + doc = request.execute() + + return _extract(doc, "key") + +TopItem = collections.namedtuple("TopItem", ["item", "weight"]) +SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) +LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) +PlayedTrack = collections.namedtuple("PlayedTrack", ["track", "playback_date", "timestamp"]) +LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) +ImageSizes = collections.namedtuple("ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"]) +Image = collections.namedtuple("Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"]) +Shout = collections.namedtuple("Shout", ["body", "author", "date"]) + +def _string_output(funct): + def r(*args): + return _string(funct(*args)) + + return r + +def _pad_list(given_list, desired_length, padding = None): + """ + Pads a list to be of the desired_length. + """ + + while len(given_list) < desired_length: + given_list.append(padding) + + return given_list + +class _BaseObject(object): + """An abstract webservices object.""" + + network = None + + def __init__(self, network): + self.network = network + + def _request(self, method_name, cacheable = False, params = None): + if not params: + params = self._get_params() + + return _Request(self.network, method_name, params).execute(cacheable) + + def _get_params(self): + """Returns the most common set of parameters between all objects.""" + + return {} + + def __hash__(self): + return hash(self.network) + \ + hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(self._get_params().values())).lower()) + +class _Taggable(object): + """Common functions for classes with tags.""" + + def __init__(self, ws_prefix): + self.ws_prefix = ws_prefix + + def add_tags(self, tags): + """Adds one or several tags. + * tags: A sequence of tag names or Tag objects. + """ + + for tag in tags: + self.add_tag(tag) + + def add_tag(self, tag): + """Adds one tag. + * tag: a tag name or a Tag object. + """ + + if isinstance(tag, Tag): + tag = tag.get_name() + + params = self._get_params() + params['tags'] = tag + + self._request(self.ws_prefix + '.addTags', False, params) + + def remove_tag(self, tag): + """Remove a user's tag from this object.""" + + if isinstance(tag, Tag): + tag = tag.get_name() + + params = self._get_params() + params['tag'] = tag + + self._request(self.ws_prefix + '.removeTag', False, params) + + def get_tags(self): + """Returns a list of the tags set by the user to this object.""" + + # Uncacheable because it can be dynamically changed by the user. + params = self._get_params() + + doc = self._request(self.ws_prefix + '.getTags', False, params) + tag_names = _extract_all(doc, 'name') + tags = [] + for tag in tag_names: + tags.append(Tag(tag, self.network)) + + return tags + + def remove_tags(self, tags): + """Removes one or several tags from this object. + * tags: a sequence of tag names or Tag objects. + """ + + for tag in tags: + self.remove_tag(tag) + + def clear_tags(self): + """Clears all the user-set tags. """ + + self.remove_tags(*(self.get_tags())) + + def set_tags(self, tags): + """Sets this object's tags to only those tags. + * tags: a sequence of tag names or Tag objects. + """ + + c_old_tags = [] + old_tags = [] + c_new_tags = [] + new_tags = [] + + to_remove = [] + to_add = [] + + tags_on_server = self.get_tags() + + for tag in tags_on_server: + c_old_tags.append(tag.get_name().lower()) + old_tags.append(tag.get_name()) + + for tag in tags: + c_new_tags.append(tag.lower()) + new_tags.append(tag) + + for i in range(0, len(old_tags)): + if not c_old_tags[i] in c_new_tags: + to_remove.append(old_tags[i]) + + for i in range(0, len(new_tags)): + if not c_new_tags[i] in c_old_tags: + to_add.append(new_tags[i]) + + self.remove_tags(to_remove) + self.add_tags(to_add) + + def get_top_tags(self, limit=None): + """Returns a list of the most frequently used Tags on this object.""" + + doc = self._request(self.ws_prefix + '.getTopTags', True) + + elements = doc.getElementsByTagName('tag') + seq = [] + + for element in elements: + tag_name = _extract(element, 'name') + tagcount = _extract(element, 'count') + + seq.append(TopItem(Tag(tag_name, self.network), tagcount)) + + if limit: + seq = seq[:limit] + + return seq + +class WSError(Exception): + """Exception related to the Network web service""" + + def __init__(self, network, status, details): + self.status = status + self.details = details + self.network = network + + @_string_output + def __str__(self): + return self.details + + def get_id(self): + """Returns the exception ID, from one of the following: + STATUS_INVALID_SERVICE = 2 + STATUS_INVALID_METHOD = 3 + STATUS_AUTH_FAILED = 4 + STATUS_INVALID_FORMAT = 5 + STATUS_INVALID_PARAMS = 6 + STATUS_INVALID_RESOURCE = 7 + STATUS_TOKEN_ERROR = 8 + STATUS_INVALID_SK = 9 + STATUS_INVALID_API_KEY = 10 + STATUS_OFFLINE = 11 + STATUS_SUBSCRIBERS_ONLY = 12 + STATUS_TOKEN_UNAUTHORIZED = 14 + STATUS_TOKEN_EXPIRED = 15 + """ + + return self.status + +class MalformedResponseError(Exception): + """Exception conveying a malformed response from Last.fm.""" + + def __init__(self, network, underlying_error): + self.network = network + self.underlying_error = underlying_error + + def __str__(self): + return "Malformed response from Last.fm. Underlying error: %s" %str(self.underlying_error) + +class NetworkError(Exception): + """Exception conveying a problem in sending a request to Last.fm""" + + def __init__(self, network, underlying_error): + self.network = network + self.underlying_error = underlying_error + + def __str__(self): + return "NetworkError: %s" %str(self.underlying_error) + +class Album(_BaseObject, _Taggable): + """An album.""" + + title = None + artist = None + + def __init__(self, artist, title, network): + """ + Create an album instance. + # Parameters: + * artist: An artist name or an Artist object. + * title: The album title. + """ + + _BaseObject.__init__(self, network) + _Taggable.__init__(self, 'album') + + if isinstance(artist, Artist): + self.artist = artist + else: + self.artist = Artist(artist, self.network) + + self.title = title + + def __repr__(self): + return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) + + @_string_output + def __str__(self): + return _unicode("%s - %s") %(self.get_artist().get_name(), self.get_title()) + + def __eq__(self, other): + return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) + + def __ne__(self, other): + return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) + + def _get_params(self): + return {'artist': self.get_artist().get_name(), 'album': self.get_title(), } + + def get_artist(self): + """Returns the associated Artist object.""" + + return self.artist + + def get_title(self): + """Returns the album title.""" + + return self.title + + def get_name(self): + """Returns the album title (alias to Album.get_title).""" + + return self.get_title() + + def get_release_date(self): + """Retruns the release date of the album.""" + + return _extract(self._request("album.getInfo", cacheable = True), "releasedate") + + def get_cover_image(self, size = COVER_EXTRA_LARGE): + """ + Returns a uri to the cover image + size can be one of: + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ + + return _extract_all(self._request("album.getInfo", cacheable = True), 'image')[size] + + def get_id(self): + """Returns the ID""" + + return _extract(self._request("album.getInfo", cacheable = True), "id") + + def get_playcount(self): + """Returns the number of plays on the network""" + + return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) + + def get_listener_count(self): + """Returns the number of liteners on the network""" + + return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) + + def get_top_tags(self, limit=None): + """Returns a list of the most-applied tags to this album.""" + + doc = self._request("album.getInfo", True) + e = doc.getElementsByTagName("toptags")[0] + + seq = [] + for name in _extract_all(e, "name"): + seq.append(Tag(name, self.network)) + + if limit: + seq = seq[:limit] + + return seq + + def get_tracks(self): + """Returns the list of Tracks on this album.""" + + uri = 'lastfm://playlist/album/%s' %self.get_id() + + return XSPF(uri, self.network).get_tracks() + + def get_mbid(self): + """Returns the MusicBrainz id of the album.""" + + return _extract(self._request("album.getInfo", cacheable = True), "mbid") + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the album page on the network. + # Parameters: + * domain_name str: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + artist = _url_safe(self.get_artist().get_name()) + album = _url_safe(self.get_title()) + + return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album} + + def get_wiki_published_date(self): + """Returns the date of publishing this version of the wiki.""" + + doc = self._request("album.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "published") + + def get_wiki_summary(self): + """Returns the summary of the wiki.""" + + doc = self._request("album.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "summary") + + def get_wiki_content(self): + """Returns the content of the wiki.""" + + doc = self._request("album.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "content") + +class Artist(_BaseObject, _Taggable): + """An artist.""" + + name = None + + def __init__(self, name, network): + """Create an artist object. + # Parameters: + * name str: The artist's name. + """ + + _BaseObject.__init__(self, network) + _Taggable.__init__(self, 'artist') + + self.name = name + + def __repr__(self): + return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + + def __eq__(self, other): + return self.get_name().lower() == other.get_name().lower() + + def __ne__(self, other): + return self.get_name().lower() != other.get_name().lower() + + def _get_params(self): + return {'artist': self.get_name()} + + def get_name(self, properly_capitalized=False): + """Returns the name of the artist. + If properly_capitalized was asserted then the name would be downloaded + overwriting the given one.""" + + if properly_capitalized: + self.name = _extract(self._request("artist.getInfo", True), "name") + + return self.name + + def get_cover_image(self, size = COVER_MEGA): + """ + Returns a uri to the cover image + size can be one of: + COVER_MEGA + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ + + return _extract_all(self._request("artist.getInfo", True), "image")[size] + + def get_playcount(self): + """Returns the number of plays on the network.""" + + return _number(_extract(self._request("artist.getInfo", True), "playcount")) + + def get_mbid(self): + """Returns the MusicBrainz ID of this artist.""" + + doc = self._request("artist.getInfo", True) + + return _extract(doc, "mbid") + + def get_listener_count(self): + """Returns the number of liteners on the network.""" + + if hasattr(self, "listener_count"): + return self.listener_count + else: + self.listener_count = _number(_extract(self._request("artist.getInfo", True), "listeners")) + return self.listener_count + + def is_streamable(self): + """Returns True if the artist is streamable.""" + + return bool(_number(_extract(self._request("artist.getInfo", True), "streamable"))) + + def get_bio_published_date(self): + """Returns the date on which the artist's biography was published.""" + + return _extract(self._request("artist.getInfo", True), "published") + + def get_bio_summary(self, language=None): + """Returns the summary of the artist's biography.""" + + if language: + params = self._get_params() + params["lang"] = language + else: + params = None + + return _extract(self._request("artist.getInfo", True, params), "summary") + + def get_bio_content(self, language=None): + """Returns the content of the artist's biography.""" + + if language: + params = self._get_params() + params["lang"] = language + else: + params = None + + return _extract(self._request("artist.getInfo", True, params), "content") + + def get_upcoming_events(self): + """Returns a list of the upcoming Events for this artist.""" + + doc = self._request('artist.getEvents', True) + + ids = _extract_all(doc, 'id') + + events = [] + for e_id in ids: + events.append(Event(e_id, self.network)) + + return events + + def get_similar(self, limit = None): + """Returns the similar artists on the network.""" + + params = self._get_params() + if limit: + params['limit'] = limit + + doc = self._request('artist.getSimilar', True, params) + + names = _extract_all(doc, "name") + matches = _extract_all(doc, "match") + + artists = [] + for i in range(0, len(names)): + artists.append(SimilarItem(Artist(names[i], self.network), _number(matches[i]))) + + return artists + + def get_top_albums(self): + """Retuns a list of the top albums.""" + + doc = self._request('artist.getTopAlbums', True) + + seq = [] + + for node in doc.getElementsByTagName("album"): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _extract(node, "playcount") + + seq.append(TopItem(Album(artist, name, self.network), playcount)) + + return seq + + def get_top_tracks(self): + """Returns a list of the most played Tracks by this artist.""" + + doc = self._request("artist.getTopTracks", True) + + seq = [] + for track in doc.getElementsByTagName('track'): + + title = _extract(track, "name") + artist = _extract(track, "name", 1) + playcount = _number(_extract(track, "playcount")) + + seq.append( TopItem(Track(artist, title, self.network), playcount) ) + + return seq + + def get_top_fans(self, limit = None): + """Returns a list of the Users who played this artist the most. + # Parameters: + * limit int: Max elements. + """ + + doc = self._request('artist.getTopFans', True) + + seq = [] + + elements = doc.getElementsByTagName('user') + + for element in elements: + if limit and len(seq) >= limit: + break + + name = _extract(element, 'name') + weight = _number(_extract(element, 'weight')) + + seq.append(TopItem(User(name, self.network), weight)) + + return seq + + def share(self, users, message = None): + """Shares this artist (sends out recommendations). + # Parameters: + * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. + * message str: A message to include in the recommendation message. + """ + + #last.fm currently accepts a max of 10 recipient at a time + while(len(users) > 10): + section = users[0:9] + users = users[9:] + self.share(section, message) + + nusers = [] + for user in users: + if isinstance(user, User): + nusers.append(user.get_name()) + else: + nusers.append(user) + + params = self._get_params() + recipients = ','.join(nusers) + params['recipient'] = recipients + if message: + params['message'] = message + + self._request('artist.share', False, params) + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the artist page on the network. + # Parameters: + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + artist = _url_safe(self.get_name()) + + return self.network._get_url(domain_name, "artist") %{'artist': artist} + + def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): + """ + Returns a sequence of Image objects + if limit is None it will return all + order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE. + + If limit==None, it will try to pull all the available data. + """ + + images = [] + + params = self._get_params() + params["order"] = order + nodes = _collect_nodes(limit, self, "artist.getImages", True, params) + for e in nodes: + if _extract(e, "name"): + user = User(_extract(e, "name"), self.network) + else: + user = None + + images.append(Image( + _extract(e, "title"), + _extract(e, "url"), + _extract(e, "dateadded"), + _extract(e, "format"), + user, + ImageSizes(*_extract_all(e, "size")), + (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) + ) + ) + return images + + def get_shouts(self, limit=50): + """ + Returns a sequqence of Shout objects + """ + + shouts = [] + for node in _collect_nodes(limit, self, "artist.getShouts", False): + shouts.append(Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) + return shouts + + def shout(self, message): + """ + Post a shout + """ + + params = self._get_params() + params["message"] = message + + self._request("artist.Shout", False, params) + + +class Event(_BaseObject): + """An event.""" + + id = None + + def __init__(self, event_id, network): + _BaseObject.__init__(self, network) + + self.id = event_id + + def __repr__(self): + return "pylast.Event(%s, %s)" %(repr(self.id), repr(self.network)) + + @_string_output + def __str__(self): + return "Event #" + self.get_id() + + def __eq__(self, other): + return self.get_id() == other.get_id() + + def __ne__(self, other): + return self.get_id() != other.get_id() + + def _get_params(self): + return {'event': self.get_id()} + + def attend(self, attending_status): + """Sets the attending status. + * attending_status: The attending status. Possible values: + o EVENT_ATTENDING + o EVENT_MAYBE_ATTENDING + o EVENT_NOT_ATTENDING + """ + + params = self._get_params() + params['status'] = attending_status + + self._request('event.attend', False, params) + + def get_attendees(self): + """ + Get a list of attendees for an event + """ + + doc = self._request("event.getAttendees", False) + + users = [] + for name in _extract_all(doc, "name"): + users.append(User(name, self.network)) + + return users + + def get_id(self): + """Returns the id of the event on the network. """ + + return self.id + + def get_title(self): + """Returns the title of the event. """ + + doc = self._request("event.getInfo", True) + + return _extract(doc, "title") + + def get_headliner(self): + """Returns the headliner of the event. """ + + doc = self._request("event.getInfo", True) + + return Artist(_extract(doc, "headliner"), self.network) + + def get_artists(self): + """Returns a list of the participating Artists. """ + + doc = self._request("event.getInfo", True) + names = _extract_all(doc, "artist") + + artists = [] + for name in names: + artists.append(Artist(name, self.network)) + + return artists + + def get_venue(self): + """Returns the venue where the event is held.""" + + doc = self._request("event.getInfo", True) + + v = doc.getElementsByTagName("venue")[0] + venue_id = _number(_extract(v, "id")) + + return Venue(venue_id, self.network) + + def get_start_date(self): + """Returns the date when the event starts.""" + + doc = self._request("event.getInfo", True) + + return _extract(doc, "startDate") + + def get_description(self): + """Returns the description of the event. """ + + doc = self._request("event.getInfo", True) + + return _extract(doc, "description") + + def get_cover_image(self, size = COVER_MEGA): + """ + Returns a uri to the cover image + size can be one of: + COVER_MEGA + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ + + doc = self._request("event.getInfo", True) + + return _extract_all(doc, "image")[size] + + def get_attendance_count(self): + """Returns the number of attending people. """ + + doc = self._request("event.getInfo", True) + + return _number(_extract(doc, "attendance")) + + def get_review_count(self): + """Returns the number of available reviews for this event. """ + + doc = self._request("event.getInfo", True) + + return _number(_extract(doc, "reviews")) + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the event page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + return self.network._get_url(domain_name, "event") %{'id': self.get_id()} + + def share(self, users, message = None): + """Shares this event (sends out recommendations). + * users: A list that can contain usernames, emails, User objects, or all of them. + * message: A message to include in the recommendation message. + """ + + #last.fm currently accepts a max of 10 recipient at a time + while(len(users) > 10): + section = users[0:9] + users = users[9:] + self.share(section, message) + + nusers = [] + for user in users: + if isinstance(user, User): + nusers.append(user.get_name()) + else: + nusers.append(user) + + params = self._get_params() + recipients = ','.join(nusers) + params['recipient'] = recipients + if message: + params['message'] = message + + self._request('event.share', False, params) + + def get_shouts(self, limit=50): + """ + Returns a sequqence of Shout objects + """ + + shouts = [] + for node in _collect_nodes(limit, self, "event.getShouts", False): + shouts.append(Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) + return shouts + + def shout(self, message): + """ + Post a shout + """ + + params = self._get_params() + params["message"] = message + + self._request("event.Shout", False, params) + +class Country(_BaseObject): + """A country at Last.fm.""" + + name = None + + def __init__(self, name, network): + _BaseObject.__init__(self, network) + + self.name = name + + def __repr__(self): + return "pylast.Country(%s, %s)" %(repr(self.name), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + + def __eq__(self, other): + return self.get_name().lower() == other.get_name().lower() + + def __ne__(self, other): + return self.get_name() != other.get_name() + + def _get_params(self): + return {'country': self.get_name()} + + def _get_name_from_code(self, alpha2code): + # TODO: Have this function lookup the alpha-2 code and return the country name. + + return alpha2code + + def get_name(self): + """Returns the country name. """ + + return self.name + + def get_top_artists(self): + """Returns a sequence of the most played artists.""" + + doc = self._request('geo.getTopArtists', True) + + seq = [] + for node in doc.getElementsByTagName("artist"): + name = _extract(node, 'name') + playcount = _extract(node, "playcount") + + seq.append(TopItem(Artist(name, self.network), playcount)) + + return seq + + def get_top_tracks(self): + """Returns a sequence of the most played tracks""" + + doc = self._request("geo.getTopTracks", True) + + seq = [] + + for n in doc.getElementsByTagName('track'): + + title = _extract(n, 'name') + artist = _extract(n, 'name', 1) + playcount = _number(_extract(n, "playcount")) + + seq.append( TopItem(Track(artist, title, self.network), playcount)) + + return seq + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the event page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + country_name = _url_safe(self.get_name()) + + return self.network._get_url(domain_name, "country") %{'country_name': country_name} + + +class Library(_BaseObject): + """A user's Last.fm library.""" + + user = None + + def __init__(self, user, network): + _BaseObject.__init__(self, network) + + if isinstance(user, User): + self.user = user + else: + self.user = User(user, self.network) + + self._albums_index = 0 + self._artists_index = 0 + self._tracks_index = 0 + + def __repr__(self): + return "pylast.Library(%s, %s)" %(repr(self.user), repr(self.network)) + + @_string_output + def __str__(self): + return repr(self.get_user()) + "'s Library" + + def _get_params(self): + return {'user': self.user.get_name()} + + def get_user(self): + """Returns the user who owns this library.""" + + return self.user + + def add_album(self, album): + """Add an album to this library.""" + + params = self._get_params() + params["artist"] = album.get_artist.get_name() + params["album"] = album.get_name() + + self._request("library.addAlbum", False, params) + + def add_artist(self, artist): + """Add an artist to this library.""" + + params = self._get_params() + params["artist"] = artist.get_name() + + self._request("library.addArtist", False, params) + + def add_track(self, track): + """Add a track to this library.""" + + params = self._get_params() + params["track"] = track.get_title() + + self._request("library.addTrack", False, params) + + def get_albums(self, artist=None, limit=50): + """ + Returns a sequence of Album objects + If no artist is specified, it will return all, sorted by playcount descendingly. + If limit==None it will return all (may take a while) + """ + + params = self._get_params() + if artist: + params["artist"] = artist + + seq = [] + for node in _collect_nodes(limit, self, "library.getAlbums", True, params): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _number(_extract(node, "playcount")) + tagcount = _number(_extract(node, "tagcount")) + + seq.append(LibraryItem(Album(artist, name, self.network), playcount, tagcount)) + + return seq + + def get_artists(self, limit=50): + """ + Returns a sequence of Album objects + if limit==None it will return all (may take a while) + """ + + seq = [] + for node in _collect_nodes(limit, self, "library.getArtists", True): + name = _extract(node, "name") + + playcount = _number(_extract(node, "playcount")) + tagcount = _number(_extract(node, "tagcount")) + + seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) + + return seq + + def get_tracks(self, artist=None, album=None, limit=50): + """ + Returns a sequence of Album objects + If limit==None it will return all (may take a while) + """ + + params = self._get_params() + if artist: + params["artist"] = artist + if album: + params["album"] = album + + seq = [] + for node in _collect_nodes(limit, self, "library.getTracks", True, params): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _number(_extract(node, "playcount")) + tagcount = _number(_extract(node, "tagcount")) + + seq.append(LibraryItem(Track(artist, name, self.network), playcount, tagcount)) + + return seq + + +class Playlist(_BaseObject): + """A Last.fm user playlist.""" + + id = None + user = None + + def __init__(self, user, id, network): + _BaseObject.__init__(self, network) + + if isinstance(user, User): + self.user = user + else: + self.user = User(user, self.network) + + self.id = id + + @_string_output + def __str__(self): + return repr(self.user) + "'s playlist # " + repr(self.id) + + def _get_info_node(self): + """Returns the node from user.getPlaylists where this playlist's info is.""" + + doc = self._request("user.getPlaylists", True) + + for node in doc.getElementsByTagName("playlist"): + if _extract(node, "id") == str(self.get_id()): + return node + + def _get_params(self): + return {'user': self.user.get_name(), 'playlistID': self.get_id()} + + def get_id(self): + """Returns the playlist id.""" + + return self.id + + def get_user(self): + """Returns the owner user of this playlist.""" + + return self.user + + def get_tracks(self): + """Returns a list of the tracks on this user playlist.""" + + uri = _unicode('lastfm://playlist/%s') %self.get_id() + + return XSPF(uri, self.network).get_tracks() + + def add_track(self, track): + """Adds a Track to this Playlist.""" + + params = self._get_params() + params['artist'] = track.get_artist().get_name() + params['track'] = track.get_title() + + self._request('playlist.addTrack', False, params) + + def get_title(self): + """Returns the title of this playlist.""" + + return _extract(self._get_info_node(), "title") + + def get_creation_date(self): + """Returns the creation date of this playlist.""" + + return _extract(self._get_info_node(), "date") + + def get_size(self): + """Returns the number of tracks in this playlist.""" + + return _number(_extract(self._get_info_node(), "size")) + + def get_description(self): + """Returns the description of this playlist.""" + + return _extract(self._get_info_node(), "description") + + def get_duration(self): + """Returns the duration of this playlist in milliseconds.""" + + return _number(_extract(self._get_info_node(), "duration")) + + def is_streamable(self): + """Returns True if the playlist is streamable. + For a playlist to be streamable, it needs at least 45 tracks by 15 different artists.""" + + if _extract(self._get_info_node(), "streamable") == '1': + return True + else: + return False + + def has_track(self, track): + """Checks to see if track is already in the playlist. + * track: Any Track object. + """ + + return track in self.get_tracks() + + def get_cover_image(self, size = COVER_EXTRA_LARGE): + """ + Returns a uri to the cover image + size can be one of: + COVER_MEGA + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ + + return _extract(self._get_info_node(), "image")[size] + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the playlist on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + english_url = _extract(self._get_info_node(), "url") + appendix = english_url[english_url.rfind("/") + 1:] + + return self.network._get_url(domain_name, "playlist") %{'appendix': appendix, "user": self.get_user().get_name()} + + +class Tag(_BaseObject): + """A Last.fm object tag.""" + + # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it) + + name = None + + def __init__(self, name, network): + _BaseObject.__init__(self, network) + + self.name = name + + def __repr__(self): + return "pylast.Tag(%s, %s)" %(repr(self.name), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + + def __eq__(self, other): + return self.get_name().lower() == other.get_name().lower() + + def __ne__(self, other): + return self.get_name().lower() != other.get_name().lower() + + def _get_params(self): + return {'tag': self.get_name()} + + def get_name(self, properly_capitalized=False): + """Returns the name of the tag. """ + + if properly_capitalized: + self.name = _extract(self._request("tag.getInfo", True), "name") + + return self.name + + def get_similar(self): + """Returns the tags similar to this one, ordered by similarity. """ + + doc = self._request('tag.getSimilar', True) + + seq = [] + names = _extract_all(doc, 'name') + for name in names: + seq.append(Tag(name, self.network)) + + return seq + + def get_top_albums(self): + """Retuns a list of the top albums.""" + + doc = self._request('tag.getTopAlbums', True) + + seq = [] + + for node in doc.getElementsByTagName("album"): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _extract(node, "playcount") + + seq.append(TopItem(Album(artist, name, self.network), playcount)) + + return seq + + def get_top_tracks(self): + """Returns a list of the most played Tracks by this artist.""" + + doc = self._request("tag.getTopTracks", True) + + seq = [] + for track in doc.getElementsByTagName('track'): + + title = _extract(track, "name") + artist = _extract(track, "name", 1) + playcount = _number(_extract(track, "playcount")) + + seq.append( TopItem(Track(artist, title, self.network), playcount) ) + + return seq + + def get_top_artists(self): + """Returns a sequence of the most played artists.""" + + doc = self._request('tag.getTopArtists', True) + + seq = [] + for node in doc.getElementsByTagName("artist"): + name = _extract(node, 'name') + playcount = _extract(node, "playcount") + + seq.append(TopItem(Artist(name, self.network), playcount)) + + return seq + + def get_weekly_chart_dates(self): + """Returns a list of From and To tuples for the available charts.""" + + doc = self._request("tag.getWeeklyChartList", True) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + + return seq + + def get_weekly_artist_charts(self, from_date = None, to_date = None): + """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("tag.getWeeklyArtistChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("artist"): + item = Artist(_extract(node, "name"), self.network) + weight = _number(_extract(node, "weight")) + seq.append(TopItem(item, weight)) + + return seq + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the tag page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + name = _url_safe(self.get_name()) + + return self.network._get_url(domain_name, "tag") %{'name': name} + +class Track(_BaseObject, _Taggable): + """A Last.fm track.""" + + artist = None + title = None + + def __init__(self, artist, title, network): + _BaseObject.__init__(self, network) + _Taggable.__init__(self, 'track') + + if isinstance(artist, Artist): + self.artist = artist + else: + self.artist = Artist(artist, self.network) + + self.title = title + + def __repr__(self): + return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_artist().get_name() + ' - ' + self.get_title() + + def __eq__(self, other): + return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) + + def __ne__(self, other): + return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) + + def _get_params(self): + return {'artist': self.get_artist().get_name(), 'track': self.get_title()} + + def get_artist(self): + """Returns the associated Artist object.""" + + return self.artist + + def get_title(self, properly_capitalized=False): + """Returns the track title.""" + + if properly_capitalized: + self.title = _extract(self._request("track.getInfo", True), "name") + + return self.title + + def get_name(self, properly_capitalized=False): + """Returns the track title (alias to Track.get_title).""" + + return self.get_title(properly_capitalized) + + def get_id(self): + """Returns the track id on the network.""" + + doc = self._request("track.getInfo", True) + + return _extract(doc, "id") + + def get_duration(self): + """Returns the track duration.""" + + doc = self._request("track.getInfo", True) + + return _number(_extract(doc, "duration")) + + def get_mbid(self): + """Returns the MusicBrainz ID of this track.""" + + doc = self._request("track.getInfo", True) + + return _extract(doc, "mbid") + + def get_listener_count(self): + """Returns the listener count.""" + + if hasattr(self, "listener_count"): + return self.listener_count + else: + doc = self._request("track.getInfo", True) + self.listener_count = _number(_extract(doc, "listeners")) + return self.listener_count + + def get_playcount(self): + """Returns the play count.""" + + doc = self._request("track.getInfo", True) + return _number(_extract(doc, "playcount")) + + def is_streamable(self): + """Returns True if the track is available at Last.fm.""" + + doc = self._request("track.getInfo", True) + return _extract(doc, "streamable") == "1" + + def is_fulltrack_available(self): + """Returns True if the fulltrack is available for streaming.""" + + doc = self._request("track.getInfo", True) + return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" + + def get_album(self): + """Returns the album object of this track.""" + + doc = self._request("track.getInfo", True) + + albums = doc.getElementsByTagName("album") + + if len(albums) == 0: + return + + node = doc.getElementsByTagName("album")[0] + return Album(_extract(node, "artist"), _extract(node, "title"), self.network) + + def get_wiki_published_date(self): + """Returns the date of publishing this version of the wiki.""" + + doc = self._request("track.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "published") + + def get_wiki_summary(self): + """Returns the summary of the wiki.""" + + doc = self._request("track.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "summary") + + def get_wiki_content(self): + """Returns the content of the wiki.""" + + doc = self._request("track.getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "content") + + def love(self): + """Adds the track to the user's loved tracks. """ + + self._request('track.love') + + def ban(self): + """Ban this track from ever playing on the radio. """ + + self._request('track.ban') + + def get_similar(self): + """Returns similar tracks for this track on the network, based on listening data. """ + + doc = self._request('track.getSimilar', True) + + seq = [] + for node in doc.getElementsByTagName("track"): + title = _extract(node, 'name') + artist = _extract(node, 'name', 1) + match = _number(_extract(node, "match")) + + seq.append(SimilarItem(Track(artist, title, self.network), match)) + + return seq + + def get_top_fans(self, limit = None): + """Returns a list of the Users who played this track.""" + + doc = self._request('track.getTopFans', True) + + seq = [] + + elements = doc.getElementsByTagName('user') + + for element in elements: + if limit and len(seq) >= limit: + break + + name = _extract(element, 'name') + weight = _number(_extract(element, 'weight')) + + seq.append(TopItem(User(name, self.network), weight)) + + return seq + + def share(self, users, message = None): + """Shares this track (sends out recommendations). + * users: A list that can contain usernames, emails, User objects, or all of them. + * message: A message to include in the recommendation message. + """ + + #last.fm currently accepts a max of 10 recipient at a time + while(len(users) > 10): + section = users[0:9] + users = users[9:] + self.share(section, message) + + nusers = [] + for user in users: + if isinstance(user, User): + nusers.append(user.get_name()) + else: + nusers.append(user) + + params = self._get_params() + recipients = ','.join(nusers) + params['recipient'] = recipients + if message: + params['message'] = message + + self._request('track.share', False, params) + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the track page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + artist = _url_safe(self.get_artist().get_name()) + title = _url_safe(self.get_title()) + + return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} + + def get_shouts(self, limit=50): + """ + Returns a sequqence of Shout objects + """ + + shouts = [] + for node in _collect_nodes(limit, self, "track.getShouts", False): + shouts.append(Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) + return shouts + +class Group(_BaseObject): + """A Last.fm group.""" + + name = None + + def __init__(self, group_name, network): + _BaseObject.__init__(self, network) + + self.name = group_name + + def __repr__(self): + return "pylast.Group(%s, %s)" %(repr(self.name), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + + def __eq__(self, other): + return self.get_name().lower() == other.get_name().lower() + + def __ne__(self, other): + return self.get_name() != other.get_name() + + def _get_params(self): + return {'group': self.get_name()} + + def get_name(self): + """Returns the group name. """ + return self.name + + def get_weekly_chart_dates(self): + """Returns a list of From and To tuples for the available charts.""" + + doc = self._request("group.getWeeklyChartList", True) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + + return seq + + def get_weekly_artist_charts(self, from_date = None, to_date = None): + """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("group.getWeeklyArtistChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("artist"): + item = Artist(_extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_album_charts(self, from_date = None, to_date = None): + """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("group.getWeeklyAlbumChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("album"): + item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_track_charts(self, from_date = None, to_date = None): + """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("group.getWeeklyTrackChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("track"): + item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the group page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + name = _url_safe(self.get_name()) + + return self.network._get_url(domain_name, "group") %{'name': name} + + def get_members(self, limit=50): + """ + Returns a sequence of User objects + if limit==None it will return all + """ + + nodes = _collect_nodes(limit, self, "group.getMembers", False) + + users = [] + + for node in nodes: + users.append(User(_extract(node, "name"), self.network)) + + return users + +class XSPF(_BaseObject): + "A Last.fm XSPF playlist.""" + + uri = None + + def __init__(self, uri, network): + _BaseObject.__init__(self, network) + + self.uri = uri + + def _get_params(self): + return {'playlistURL': self.get_uri()} + + @_string_output + def __str__(self): + return self.get_uri() + + def __eq__(self, other): + return self.get_uri() == other.get_uri() + + def __ne__(self, other): + return self.get_uri() != other.get_uri() + + def get_uri(self): + """Returns the Last.fm playlist URI. """ + + return self.uri + + def get_tracks(self): + """Returns the tracks on this playlist.""" + + doc = self._request('playlist.fetch', True) + + seq = [] + for n in doc.getElementsByTagName('track'): + title = _extract(n, 'title') + artist = _extract(n, 'creator') + + seq.append(Track(artist, title, self.network)) + + return seq + +class User(_BaseObject): + """A Last.fm user.""" + + name = None + + def __init__(self, user_name, network): + _BaseObject.__init__(self, network) + + self.name = user_name + + self._past_events_index = 0 + self._recommended_events_index = 0 + self._recommended_artists_index = 0 + + def __repr__(self): + return "pylast.User(%s, %s)" %(repr(self.name), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + + def __eq__(self, another): + return self.get_name() == another.get_name() + + def __ne__(self, another): + return self.get_name() != another.get_name() + + def _get_params(self): + return {"user": self.get_name()} + + def get_name(self, properly_capitalized=False): + """Returns the nuser name.""" + + if properly_capitalized: + self.name = _extract(self._request("user.getInfo", True), "name") + + return self.name + + def get_upcoming_events(self): + """Returns all the upcoming events for this user. """ + + doc = self._request('user.getEvents', True) + + ids = _extract_all(doc, 'id') + events = [] + + for e_id in ids: + events.append(Event(e_id, self.network)) + + return events + + def get_friends(self, limit = 50): + """Returns a list of the user's friends. """ + + seq = [] + for node in _collect_nodes(limit, self, "user.getFriends", False): + seq.append(User(_extract(node, "name"), self.network)) + + return seq + + def get_loved_tracks(self, limit=50): + """Returns this user's loved track as a sequence of LovedTrack objects + in reverse order of their timestamp, all the way back to the first track. + + If limit==None, it will try to pull all the available data. + + This method uses caching. Enable caching only if you're pulling a + large amount of data. + + Use extract_items() with the return of this function to + get only a sequence of Track objects with no playback dates. """ + + params = self._get_params() + if limit: + params['limit'] = limit + + seq = [] + for track in _collect_nodes(limit, self, "user.getLovedTracks", True, params): + + title = _extract(track, "name") + artist = _extract(track, "name", 1) + date = _extract(track, "date") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + + seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) + + return seq + + def get_neighbours(self, limit = 50): + """Returns a list of the user's friends.""" + + params = self._get_params() + if limit: + params['limit'] = limit + + doc = self._request('user.getNeighbours', True, params) + + seq = [] + names = _extract_all(doc, 'name') + + for name in names: + seq.append(User(name, self.network)) + + return seq + + def get_past_events(self, limit=50): + """ + Returns a sequence of Event objects + if limit==None it will return all + """ + + seq = [] + for n in _collect_nodes(limit, self, "user.getPastEvents", False): + seq.append(Event(_extract(n, "id"), self.network)) + + return seq + + def get_playlists(self): + """Returns a list of Playlists that this user owns.""" + + doc = self._request("user.getPlaylists", True) + + playlists = [] + for playlist_id in _extract_all(doc, "id"): + playlists.append(Playlist(self.get_name(), playlist_id, self.network)) + + return playlists + + def get_now_playing(self): + """Returns the currently playing track, or None if nothing is playing. """ + + params = self._get_params() + params['limit'] = '1' + + doc = self._request('user.getRecentTracks', False, params) + + e = doc.getElementsByTagName('track')[0] + + if not e.hasAttribute('nowplaying'): + return None + + artist = _extract(e, 'artist') + title = _extract(e, 'name') + + return Track(artist, title, self.network) + + + def get_recent_tracks(self, limit = 10): + """Returns this user's played track as a sequence of PlayedTrack objects + in reverse order of their playtime, all the way back to the first track. + + If limit==None, it will try to pull all the available data. + + This method uses caching. Enable caching only if you're pulling a + large amount of data. + + Use extract_items() with the return of this function to + get only a sequence of Track objects with no playback dates. """ + + params = self._get_params() + if limit: + params['limit'] = limit + + seq = [] + for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): + + if track.hasAttribute('nowplaying'): + continue #to prevent the now playing track from sneaking in here + + title = _extract(track, "name") + artist = _extract(track, "artist") + date = _extract(track, "date") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + + return seq + + def get_id(self): + """Returns the user id.""" + + doc = self._request("user.getInfo", True) + + return _extract(doc, "id") + + def get_language(self): + """Returns the language code of the language used by the user.""" + + doc = self._request("user.getInfo", True) + + return _extract(doc, "lang") + + def get_country(self): + """Returns the name of the country of the user.""" + + doc = self._request("user.getInfo", True) + + return Country(_extract(doc, "country"), self.network) + + def get_age(self): + """Returns the user's age.""" + + doc = self._request("user.getInfo", True) + + return _number(_extract(doc, "age")) + + def get_gender(self): + """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" + + doc = self._request("user.getInfo", True) + + value = _extract(doc, "gender") + + if value == 'm': + return USER_MALE + elif value == 'f': + return USER_FEMALE + + return None + + def is_subscriber(self): + """Returns whether the user is a subscriber or not. True or False.""" + + doc = self._request("user.getInfo", True) + + return _extract(doc, "subscriber") == "1" + + def get_playcount(self): + """Returns the user's playcount so far.""" + + doc = self._request("user.getInfo", True) + + return _number(_extract(doc, "playcount")) + + def get_top_albums(self, period = PERIOD_OVERALL): + """Returns the top albums played by a user. + * period: The period of time. Possible values: + o PERIOD_OVERALL + o PERIOD_7DAYS + o PERIOD_3MONTHS + o PERIOD_6MONTHS + o PERIOD_12MONTHS + """ + + params = self._get_params() + params['period'] = period + + doc = self._request('user.getTopAlbums', True, params) + + seq = [] + for album in doc.getElementsByTagName('album'): + name = _extract(album, 'name') + artist = _extract(album, 'name', 1) + playcount = _extract(album, "playcount") + + seq.append(TopItem(Album(artist, name, self.network), playcount)) + + return seq + + def get_top_artists(self, period = PERIOD_OVERALL): + """Returns the top artists played by a user. + * period: The period of time. Possible values: + o PERIOD_OVERALL + o PERIOD_7DAYS + o PERIOD_3MONTHS + o PERIOD_6MONTHS + o PERIOD_12MONTHS + """ + + params = self._get_params() + params['period'] = period + + doc = self._request('user.getTopArtists', True, params) + + seq = [] + for node in doc.getElementsByTagName('artist'): + name = _extract(node, 'name') + playcount = _extract(node, "playcount") + + seq.append(TopItem(Artist(name, self.network), playcount)) + + return seq + + def get_top_tags(self, limit=None): + """Returns a sequence of the top tags used by this user with their counts as TopItem objects. + * limit: The limit of how many tags to return. + """ + + doc = self._request("user.getTopTags", True) + + seq = [] + for node in doc.getElementsByTagName("tag"): + seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) + + if limit: + seq = seq[:limit] + + return seq + + def get_top_tracks(self, period = PERIOD_OVERALL): + """Returns the top tracks played by a user. + * period: The period of time. Possible values: + o PERIOD_OVERALL + o PERIOD_7DAYS + o PERIOD_3MONTHS + o PERIOD_6MONTHS + o PERIOD_12MONTHS + """ + + params = self._get_params() + params['period'] = period + + doc = self._request('user.getTopTracks', True, params) + + seq = [] + for track in doc.getElementsByTagName('track'): + name = _extract(track, 'name') + artist = _extract(track, 'name', 1) + playcount = _extract(track, "playcount") + + seq.append(TopItem(Track(artist, name, self.network), playcount)) + + return seq + + def get_weekly_chart_dates(self): + """Returns a list of From and To tuples for the available charts.""" + + doc = self._request("user.getWeeklyChartList", True) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + + return seq + + def get_weekly_artist_charts(self, from_date = None, to_date = None): + """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("user.getWeeklyArtistChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("artist"): + item = Artist(_extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_album_charts(self, from_date = None, to_date = None): + """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("user.getWeeklyAlbumChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("album"): + item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_track_charts(self, from_date = None, to_date = None): + """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request("user.getWeeklyTrackChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("track"): + item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def compare_with_user(self, user, shared_artists_limit = None): + """Compare this user with another Last.fm user. + Returns a sequence (tasteometer_score, (shared_artist1, shared_artist2, ...)) + user: A User object or a username string/unicode object. + """ + + if isinstance(user, User): + user = user.get_name() + + params = self._get_params() + if shared_artists_limit: + params['limit'] = shared_artists_limit + params['type1'] = 'user' + params['type2'] = 'user' + params['value1'] = self.get_name() + params['value2'] = user + + doc = self._request('tasteometer.compare', False, params) + + score = _extract(doc, 'score') + + artists = doc.getElementsByTagName('artists')[0] + shared_artists_names = _extract_all(artists, 'name') + + shared_artists_seq = [] + + for name in shared_artists_names: + shared_artists_seq.append(Artist(name, self.network)) + + return (score, shared_artists_seq) + + def get_image(self): + """Returns the user's avatar.""" + + doc = self._request("user.getInfo", True) + + return _extract(doc, "image") + + def get_url(self, domain_name = DOMAIN_ENGLISH): + """Returns the url of the user page on the network. + * domain_name: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE + """ + + name = _url_safe(self.get_name()) + + return self.network._get_url(domain_name, "user") %{'name': name} + + def get_library(self): + """Returns the associated Library object. """ + + return Library(self, self.network) + + def get_shouts(self, limit=50): + """ + Returns a sequqence of Shout objects + """ + + shouts = [] + for node in _collect_nodes(limit, self, "user.getShouts", False): + shouts.append(Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) + return shouts + + def shout(self, message): + """ + Post a shout + """ + + params = self._get_params() + params["message"] = message + + self._request("user.Shout", False, params) + +class AuthenticatedUser(User): + def __init__(self, network): + User.__init__(self, "", network); + + def _get_params(self): + return {"user": self.get_name()} + + def get_name(self): + """Returns the name of the authenticated user.""" + + doc = self._request("user.getInfo", True, {"user": ""}) # hack + + self.name = _extract(doc, "name") + return self.name + + def get_recommended_events(self, limit=50): + """ + Returns a sequence of Event objects + if limit==None it will return all + """ + + seq = [] + for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False): + seq.append(Event(_extract(node, "id"), self.network)) + + return seq + + def get_recommended_artists(self, limit=50): + """ + Returns a sequence of Event objects + if limit==None it will return all + """ + + seq = [] + for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False): + seq.append(Artist(_extract(node, "name"), self.network)) + + return seq + +class _Search(_BaseObject): + """An abstract class. Use one of its derivatives.""" + + def __init__(self, ws_prefix, search_terms, network): + _BaseObject.__init__(self, network) + + self._ws_prefix = ws_prefix + self.search_terms = search_terms + + self._last_page_index = 0 + + def _get_params(self): + params = {} + + for key in self.search_terms.keys(): + params[key] = self.search_terms[key] + + return params + + def get_total_result_count(self): + """Returns the total count of all the results.""" + + doc = self._request(self._ws_prefix + ".search", True) + + return _extract(doc, "opensearch:totalResults") + + def _retreive_page(self, page_index): + """Returns the node of matches to be processed""" + + params = self._get_params() + params["page"] = str(page_index) + doc = self._request(self._ws_prefix + ".search", True, params) + + return doc.getElementsByTagName(self._ws_prefix + "matches")[0] + + def _retrieve_next_page(self): + self._last_page_index += 1 + return self._retreive_page(self._last_page_index) + +class AlbumSearch(_Search): + """Search for an album by name.""" + + def __init__(self, album_name, network): + + _Search.__init__(self, "album", {"album": album_name}, network) + + def get_next_page(self): + """Returns the next page of results as a sequence of Album objects.""" + + master_node = self._retrieve_next_page() + + seq = [] + for node in master_node.getElementsByTagName("album"): + seq.append(Album(_extract(node, "artist"), _extract(node, "name"), self.network)) + + return seq + +class ArtistSearch(_Search): + """Search for an artist by artist name.""" + + def __init__(self, artist_name, network): + _Search.__init__(self, "artist", {"artist": artist_name}, network) + + def get_next_page(self): + """Returns the next page of results as a sequence of Artist objects.""" + + master_node = self._retrieve_next_page() + + seq = [] + for node in master_node.getElementsByTagName("artist"): + artist = Artist(_extract(node, "name"), self.network) + artist.listener_count = _number(_extract(node, "listeners")) + seq.append(artist) + + return seq + +class TagSearch(_Search): + """Search for a tag by tag name.""" + + def __init__(self, tag_name, network): + + _Search.__init__(self, "tag", {"tag": tag_name}, network) + + def get_next_page(self): + """Returns the next page of results as a sequence of Tag objects.""" + + master_node = self._retrieve_next_page() + + seq = [] + for node in master_node.getElementsByTagName("tag"): + tag = Tag(_extract(node, "name"), self.network) + tag.tag_count = _number(_extract(node, "count")) + seq.append(tag) + + return seq + +class TrackSearch(_Search): + """Search for a track by track title. If you don't wanna narrow the results down + by specifying the artist name, set it to empty string.""" + + def __init__(self, artist_name, track_title, network): + + _Search.__init__(self, "track", {"track": track_title, "artist": artist_name}, network) + + def get_next_page(self): + """Returns the next page of results as a sequence of Track objects.""" + + master_node = self._retrieve_next_page() + + seq = [] + for node in master_node.getElementsByTagName("track"): + track = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + track.listener_count = _number(_extract(node, "listeners")) + seq.append(track) + + return seq + +class VenueSearch(_Search): + """Search for a venue by its name. If you don't wanna narrow the results down + by specifying a country, set it to empty string.""" + + def __init__(self, venue_name, country_name, network): + + _Search.__init__(self, "venue", {"venue": venue_name, "country": country_name}, network) + + def get_next_page(self): + """Returns the next page of results as a sequence of Track objects.""" + + master_node = self._retrieve_next_page() + + seq = [] + for node in master_node.getElementsByTagName("venue"): + seq.append(Venue(_extract(node, "id"), self.network)) + + return seq + +class Venue(_BaseObject): + """A venue where events are held.""" + + # TODO: waiting for a venue.getInfo web service to use. + + id = None + + def __init__(self, id, network): + _BaseObject.__init__(self, network) + + self.id = _number(id) + + def __repr__(self): + return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) + + @_string_output + def __str__(self): + return "Venue #" + str(self.id) + + def __eq__(self, other): + return self.get_id() == other.get_id() + + def _get_params(self): + return {"venue": self.get_id()} + + def get_id(self): + """Returns the id of the venue.""" + + return self.id + + def get_upcoming_events(self): + """Returns the upcoming events in this venue.""" + + doc = self._request("venue.getEvents", True) + + seq = [] + for node in doc.getElementsByTagName("event"): + seq.append(Event(_extract(node, "id"), self.network)) + + return seq + + def get_past_events(self): + """Returns the past events held in this venue.""" + + doc = self._request("venue.getEvents", True) + + seq = [] + for node in doc.getElementsByTagName("event"): + seq.append(Event(_extract(node, "id"), self.network)) + + return seq + +def md5(text): + """Returns the md5 hash of a string.""" + + h = hashlib.md5() + h.update(_unicode(text).encode("utf-8")) + + return h.hexdigest() + +def _unicode(text): + if sys.version_info[0] == 3: + if type(text) in (bytes, bytearray): + return str(text, "utf-8") + elif type(text) == str: + return text + else: + return str(text) + + elif sys.version_info[0] ==2: + if type(text) in (str,): + return unicode(text, "utf-8") + elif type(text) == unicode: + return text + else: + return unicode(text) + +def _string(text): + """For Python2 routines that can only process str type.""" + + if sys.version_info[0] == 3: + if type(text) != str: + return str(text) + else: + return text + + elif sys.version_info[0] == 2: + if type(text) == str: + return text + + if type(text) == int: + return str(text) + + return text.encode("utf-8") + +def _collect_nodes(limit, sender, method_name, cacheable, params=None): + """ + Returns a sequqnce of dom.Node objects about as close to + limit as possible + """ + + if not params: + params = sender._get_params() + + nodes = [] + page = 1 + end_of_pages = False + + while not end_of_pages and (not limit or (limit and len(nodes) < limit)): + params["page"] = str(page) + doc = sender._request(method_name, cacheable, params) + + main = doc.documentElement.childNodes[1] + + if main.hasAttribute("totalPages"): + total_pages = _number(main.getAttribute("totalPages")) + elif main.hasAttribute("totalpages"): + total_pages = _number(main.getAttribute("totalpages")) + else: + raise Exception("No total pages attribute") + + for node in main.childNodes: + if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: + nodes.append(node) + + if page >= total_pages: + end_of_pages = True + + page += 1 + + return nodes + +def _extract(node, name, index = 0): + """Extracts a value from the xml string""" + + nodes = node.getElementsByTagName(name) + + if len(nodes): + if nodes[index].firstChild: + return _unescape_htmlentity(nodes[index].firstChild.data.strip()) + else: + return None + +def _extract_all(node, name, limit_count = None): + """Extracts all the values from the xml string. returning a list.""" + + seq = [] + + for i in range(0, len(node.getElementsByTagName(name))): + if len(seq) == limit_count: + break + + seq.append(_extract(node, name, i)) + + return seq + +def _url_safe(text): + """Does all kinds of tricks on a text to make it safe to use in a url.""" + + return url_quote_plus(url_quote_plus(_string(text))).lower() + +def _number(string): + """ + Extracts an int from a string. Returns a 0 if None or an empty string was passed + """ + + if not string: + return 0 + elif string == "": + return 0 + else: + try: + return int(string) + except ValueError: + return float(string) + +def _unescape_htmlentity(string): + + #string = _unicode(string) + + mapping = htmlentitydefs.name2codepoint + for key in mapping: + string = string.replace("&%s;" %key, unichr(mapping[key])) + + return string + +def extract_items(topitems_or_libraryitems): + """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects.""" + + seq = [] + for i in topitems_or_libraryitems: + seq.append(i.item) + + return seq + +class ScrobblingError(Exception): + def __init__(self, message): + Exception.__init__(self) + self.message = message + + @_string_output + def __str__(self): + return self.message + +class BannedClientError(ScrobblingError): + def __init__(self): + ScrobblingError.__init__(self, "This version of the client has been banned") + +class BadAuthenticationError(ScrobblingError): + def __init__(self): + ScrobblingError.__init__(self, "Bad authentication token") + +class BadTimeError(ScrobblingError): + def __init__(self): + ScrobblingError.__init__(self, "Time provided is not close enough to current time") + +class BadSessionError(ScrobblingError): + def __init__(self): + ScrobblingError.__init__(self, "Bad session id, consider re-handshaking") + +class _ScrobblerRequest(object): + + def __init__(self, url, params, network, type="POST"): + + for key in params: + params[key] = str(params[key]) + + self.params = params + self.type = type + (self.hostname, self.subdir) = url_split_host(url[len("http:"):]) + self.network = network + + def execute(self): + """Returns a string response of this request.""" + + connection = HTTPConnection(self.hostname) + + data = [] + for name in self.params.keys(): + value = url_quote_plus(self.params[name]) + data.append('='.join((name, value))) + data = "&".join(data) + + headers = { + "Content-type": "application/x-www-form-urlencoded", + "Accept-Charset": "utf-8", + "User-Agent": "pylast" + "/" + __version__, + "HOST": self.hostname + } + + if self.type == "GET": + connection.request("GET", self.subdir + "?" + data, headers = headers) + else: + connection.request("POST", self.subdir, data, headers) + response = _unicode(connection.getresponse().read()) + + self._check_response_for_errors(response) + + return response + + def _check_response_for_errors(self, response): + """When passed a string response it checks for erros, raising + any exceptions as necessary.""" + + lines = response.split("\n") + status_line = lines[0] + + if status_line == "OK": + return + elif status_line == "BANNED": + raise BannedClientError() + elif status_line == "BADAUTH": + raise BadAuthenticationError() + elif status_line == "BADTIME": + raise BadTimeError() + elif status_line == "BADSESSION": + raise BadSessionError() + elif status_line.startswith("FAILED "): + reason = status_line[status_line.find("FAILED ")+len("FAILED "):] + raise ScrobblingError(reason) + +class Scrobbler(object): + """A class for scrobbling tracks to Last.fm""" + + session_id = None + nowplaying_url = None + submissions_url = None + + def __init__(self, network, client_id, client_version): + self.client_id = client_id + self.client_version = client_version + self.username = network.username + self.password = network.password_hash + self.network = network + + def _do_handshake(self): + """Handshakes with the server""" + + timestamp = str(int(time.time())) + + if self.password and self.username: + token = md5(self.password + timestamp) + elif self.network.api_key and self.network.api_secret and self.network.session_key: + if not self.username: + self.username = self.network.get_authenticated_user().get_name() + token = md5(self.network.api_secret + timestamp) + + params = {"hs": "true", "p": "1.2.1", "c": self.client_id, + "v": self.client_version, "u": self.username, "t": timestamp, + "a": token} + + if self.network.session_key and self.network.api_key: + params["sk"] = self.network.session_key + params["api_key"] = self.network.api_key + + server = self.network.submission_server + response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n") + + self.session_id = response[1] + self.nowplaying_url = response[2] + self.submissions_url = response[3] + + def _get_session_id(self, new = False): + """Returns a handshake. If new is true, then it will be requested from the server + even if one was cached.""" + + if not self.session_id or new: + self._do_handshake() + + return self.session_id + + def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): + + _deprecation_warning("DeprecationWarning: Use Netowrk.update_now_playing(...) instead") + + params = {"s": self._get_session_id(), "a": artist, "t": title, + "b": album, "l": duration, "n": track_number, "m": mbid} + + try: + _ScrobblerRequest(self.nowplaying_url, params, self.network).execute() + except BadSessionError: + self._do_handshake() + self.report_now_playing(artist, title, album, duration, track_number, mbid) + + def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""): + """Scrobble a track. parameters: + artist: Artist name. + title: Track title. + time_started: UTC timestamp of when the track started playing. + source: The source of the track + SCROBBLE_SOURCE_USER: Chosen by the user (the most common value, unless you have a reason for choosing otherwise, use this). + SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1). + SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised recommendation except Last.fm (e.g. Pandora, Launchcast). + SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the 5-digit recommendation_key value must be set. + SCROBBLE_SOURCE_UNKNOWN: Source unknown. + mode: The submission mode + SCROBBLE_MODE_PLAYED: The track was played. + SCROBBLE_MODE_LOVED: The user manually loved the track (implies a listen) + SCROBBLE_MODE_SKIPPED: The track was skipped (Only if source was Last.fm) + SCROBBLE_MODE_BANNED: The track was banned (Only if source was Last.fm) + duration: Track duration in seconds. + album: The album name. + track_number: The track number on the album. + mbid: MusicBrainz ID. + """ + + _deprecation_warning("DeprecationWarning: Use Network.scrobble(...) instead") + + params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title), + "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration), + "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid} + + _ScrobblerRequest(self.submissions_url, params, self.network).execute() + + def scrobble_many(self, tracks): + """ + Scrobble several tracks at once. + + tracks: A sequence of a sequence of parameters for each trach. The order of parameters + is the same as if passed to the scrobble() method. + """ + + _deprecation_warning("DeprecationWarning: Use Network.scrobble_many(...) instead") + + remainder = [] + + if len(tracks) > 50: + remainder = tracks[50:] + tracks = tracks[:50] + + params = {"s": self._get_session_id()} + + i = 0 + for t in tracks: + _pad_list(t, 9, "") + params["a[%s]" % str(i)] = _string(t[0]) + params["t[%s]" % str(i)] = _string(t[1]) + params["i[%s]" % str(i)] = str(t[2]) + params["o[%s]" % str(i)] = t[3] + params["r[%s]" % str(i)] = t[4] + params["l[%s]" % str(i)] = str(t[5]) + params["b[%s]" % str(i)] = _string(t[6]) + params["n[%s]" % str(i)] = t[7] + params["m[%s]" % str(i)] = t[8] + + i += 1 + + _ScrobblerRequest(self.submissions_url, params, self.network).execute() + + if remainder: + self.scrobble_many(remainder) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..4964af9 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python + +from distutils.core import setup + +import os +def get_build(): + path = "./.build" + + if os.path.exists(path): + fp = open(path, "r") + build = eval(fp.read()) + if os.path.exists("./.increase_build"): + build += 1 + fp.close() + else: + build = 1 + + fp = open(path, "w") + fp.write(str(build)) + fp.close() + + return str(build) + +setup(name = "pylast", + version = "0.5." + get_build(), + author = "Amr Hassan ", + description = "A Python interface to Last.fm (and other API compatible social networks)", + author_email = "amr.hassan@gmail.com", + url = "http://code.google.com/p/pylast/", + py_modules = ("pylast",), + license = "Apache2" + ) From 79a43919ccaa42bcceac143dc9b6fdf0ed4a008c Mon Sep 17 00:00:00 2001 From: Lukas Lipka Date: Sat, 10 Mar 2012 15:03:09 +0100 Subject: [PATCH 004/768] Add support for chart artists/tracks. --- AUTHORS | 1 + pylast.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/AUTHORS b/AUTHORS index f09ae8f..053c3a0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1,2 @@ Amr Hassan +Lukas Lipka diff --git a/pylast.py b/pylast.py index aac62d9..71be2a0 100644 --- a/pylast.py +++ b/pylast.py @@ -313,6 +313,37 @@ class _Network(object): return Playlist(user, e_id, self) + def get_top_artists(self, limit=None): + """Returns a sequence of the most played artists.""" + + doc = _Request(self, "chart.getTopArtists").execute(True) + seq = [] + for node in doc.getElementsByTagName("artist"): + title = _extract(node, "name") + artist = Artist(title, self) + seq.append(artist) + + if limit: + seq = seq[:limit] + + return seq + + def get_top_tracks(self, limit=None): + """Returns a sequence of the most played tracks.""" + + doc = _Request(self, "chart.getTopTracks").execute(True) + seq = [] + for node in doc.getElementsByTagName("track"): + title = _extract(node, "name") + artist = _extract(node, "name", 1) + track = Track(artist, title, self) + seq.append(track) + + if limit: + seq = seq[:limit] + + return seq + def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" From 0400a87b0450c25a40c22fae30a8f5dc1019a47f Mon Sep 17 00:00:00 2001 From: "Elizabeth J. Myers" Date: Wed, 16 Jan 2013 11:18:29 -0600 Subject: [PATCH 005/768] Bloop --- .build | 2 +- AUTHORS | 2 -- MANIFEST.in | 6 ++++++ PKG-INFO | 10 ---------- README | 4 ++-- pylast.py | 33 --------------------------------- setup.py | 0 7 files changed, 9 insertions(+), 48 deletions(-) delete mode 100644 AUTHORS create mode 100755 MANIFEST.in delete mode 100644 PKG-INFO mode change 100644 => 100755 setup.py diff --git a/.build b/.build index 9d60796..da2d398 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -11 \ No newline at end of file +14 \ No newline at end of file diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 053c3a0..0000000 --- a/AUTHORS +++ /dev/null @@ -1,2 +0,0 @@ -Amr Hassan -Lukas Lipka diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..9e84b8c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include pylast.py +include setup.py +include README +include COPYING +include INSTALL +include .build diff --git a/PKG-INFO b/PKG-INFO deleted file mode 100644 index e6dad55..0000000 --- a/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 1.0 -Name: pylast -Version: 0.5.11 -Summary: A Python interface to Last.fm (and other API compatible social networks) -Home-page: http://code.google.com/p/pylast/ -Author: Amr Hassan -Author-email: amr.hassan@gmail.com -License: Apache2 -Description: UNKNOWN -Platform: UNKNOWN diff --git a/README b/README index 64b1b7f..923259e 100644 --- a/README +++ b/README @@ -3,5 +3,5 @@ pylast A python interface to Last.fm. Try using the pydoc utility for help on usage. -For more info check out the project's home page at http://code.google.com/p/pylast/ -or the mailing list http://groups.google.com/group/pylast/ + +Original code can be found at: http://code.google.com/p/pylast/ diff --git a/pylast.py b/pylast.py index 71be2a0..a4e0c0e 100644 --- a/pylast.py +++ b/pylast.py @@ -313,37 +313,6 @@ class _Network(object): return Playlist(user, e_id, self) - def get_top_artists(self, limit=None): - """Returns a sequence of the most played artists.""" - - doc = _Request(self, "chart.getTopArtists").execute(True) - seq = [] - for node in doc.getElementsByTagName("artist"): - title = _extract(node, "name") - artist = Artist(title, self) - seq.append(artist) - - if limit: - seq = seq[:limit] - - return seq - - def get_top_tracks(self, limit=None): - """Returns a sequence of the most played tracks.""" - - doc = _Request(self, "chart.getTopTracks").execute(True) - seq = [] - for node in doc.getElementsByTagName("track"): - title = _extract(node, "name") - artist = _extract(node, "name", 1) - track = Track(artist, title, self) - seq.append(track) - - if limit: - seq = seq[:limit] - - return seq - def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" @@ -383,8 +352,6 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - In choosing the backend used for caching, it will try _SqliteCacheBackend first if - the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 From 04cf7f7e255d40dbdf800073ac48bbdae70775dc Mon Sep 17 00:00:00 2001 From: "Elizabeth J. Myers" Date: Wed, 16 Jan 2013 11:19:41 -0600 Subject: [PATCH 006/768] Add support for user nowplaying to several classes. --- pylast.py | 52 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/pylast.py b/pylast.py index a4e0c0e..b31445a 100644 --- a/pylast.py +++ b/pylast.py @@ -721,7 +721,7 @@ class _Request(object): if self.session_key: self.params["sk"] = self.session_key self.sign_it() - + def sign_it(self): """Sign this request.""" @@ -1150,8 +1150,9 @@ class Album(_BaseObject, _Taggable): title = None artist = None - - def __init__(self, artist, title, network): + username = None + + def __init__(self, artist, title, network, username=None): """ Create an album instance. # Parameters: @@ -1168,6 +1169,7 @@ class Album(_BaseObject, _Taggable): self.artist = Artist(artist, self.network) self.title = title + self.username = username def __repr__(self): return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -1227,6 +1229,16 @@ class Album(_BaseObject, _Taggable): return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + return _number(_extract(self._request("album.getInfo", True, params), "userplaycount")) + def get_listener_count(self): """Returns the number of liteners on the network""" @@ -1322,8 +1334,9 @@ class Artist(_BaseObject, _Taggable): """An artist.""" name = None + username = None - def __init__(self, name, network): + def __init__(self, name, network, username=None): """Create an artist object. # Parameters: * name str: The artist's name. @@ -1333,6 +1346,7 @@ class Artist(_BaseObject, _Taggable): _Taggable.__init__(self, 'artist') self.name = name + self.username = username def __repr__(self): return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) @@ -1378,6 +1392,16 @@ class Artist(_BaseObject, _Taggable): return _number(_extract(self._request("artist.getInfo", True), "playcount")) + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + return _number(_extract(self._request("artist.getInfo", True, params), "userplaycount")) + def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" @@ -2320,8 +2344,9 @@ class Track(_BaseObject, _Taggable): artist = None title = None - - def __init__(self, artist, title, network): + username = None + + def __init__(self, artist, title, network, username=None): _BaseObject.__init__(self, network) _Taggable.__init__(self, 'track') @@ -2331,6 +2356,8 @@ class Track(_BaseObject, _Taggable): self.artist = Artist(artist, self.network) self.title = title + + self.username = username def __repr__(self): return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -2402,6 +2429,17 @@ class Track(_BaseObject, _Taggable): doc = self._request("track.getInfo", True) return _number(_extract(doc, "playcount")) + + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + doc = self._request("track.getInfo", True, params) + return _number(_extract(doc, "userplaycount")) def is_streamable(self): """Returns True if the track is available at Last.fm.""" @@ -2890,7 +2928,7 @@ class User(_BaseObject): artist = _extract(e, 'artist') title = _extract(e, 'name') - return Track(artist, title, self.network) + return Track(artist, title, self.network, self.name) def get_recent_tracks(self, limit = 10): From 7fadea93bd12f14320f683b17ae79d2abbd88268 Mon Sep 17 00:00:00 2001 From: "Elizabeth J. Myers" Date: Wed, 16 Jan 2013 11:21:55 -0600 Subject: [PATCH 007/768] whitespace cleanup. no functional change. --- pylast.py | 1956 ++++++++++++++++++++++++++--------------------------- 1 file changed, 978 insertions(+), 978 deletions(-) diff --git a/pylast.py b/pylast.py index b31445a..799065d 100644 --- a/pylast.py +++ b/pylast.py @@ -17,7 +17,7 @@ # limitations under the License. # # http://code.google.com/p/pylast/ - + __version__ = '0.5' __author__ = 'Amr Hassan' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan" @@ -42,7 +42,7 @@ if sys.version_info[0] == 3: import html.entities as htmlentitydefs from urllib.parse import splithost as url_split_host from urllib.parse import quote_plus as url_quote_plus - + unichr = chr elif sys.version_info[0] == 2: @@ -117,7 +117,7 @@ class _Network(object): """ A music social network website that is Last.fm or one exposing a Last.fm compatible API """ - + def __init__(self, name, homepage, ws_server, api_key, api_secret, session_key, submission_server, username, password_hash, domain_names, urls): """ @@ -132,16 +132,16 @@ class _Network(object): password_hash: the output of pylast.md5(password) where password is the user's password domain_names: a dict mapping each DOMAIN_* value to a string domain name urls: a dict mapping types to urls - + if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. - + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - + You should use a preconfigured network object through a get_*_network(...) method instead of creating an object of this class, unless you know what you're doing. """ - + self.name = name self.homepage = homepage self.ws_server = ws_server @@ -153,92 +153,92 @@ class _Network(object): self.password_hash = password_hash self.domain_names = domain_names self.urls = urls - + self.cache_backend = None self.proxy_enabled = False self.proxy = None self.last_call_time = 0 - + #generate a session_key if necessary if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): sk_gen = SessionKeyGenerator(self) self.session_key = sk_gen.get_session_key(self.username, self.password_hash) - + """def __repr__(self): attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", "session_key", "submission_server", "username", "password_hash", "domain_names", "urls") - + text = "pylast._Network(%s)" args = [] for attr in attributes: args.append("=".join((attr, repr(getattr(self, attr))))) - + return text % ", ".join(args) """ - + def __str__(self): return "The %s Network" %self.name - + def get_artist(self, artist_name): """ Return an Artist object """ - + return Artist(artist_name, self) - + def get_track(self, artist, title): """ Return a Track object """ - + return Track(artist, title, self) - + def get_album(self, artist, title): """ Return an Album object """ - + return Album(artist, title, self) - + def get_authenticated_user(self): """ Returns the authenticated user """ - + return AuthenticatedUser(self) - + def get_country(self, country_name): """ Returns a country object """ - + return Country(country_name, self) - + def get_group(self, name): """ Returns a Group object """ - + return Group(name, self) - + def get_user(self, username): """ Returns a user object """ - + return User(username, self) - + def get_tag(self, name): """ Returns a tag object """ - + return Tag(name, self) - + def get_scrobbler(self, client_id, client_version): """ Returns a Scrobbler object used for submitting tracks to the server - + Quote from http://www.last.fm/api/submissions: ======== Client identifiers are used to provide a centrally managed database of @@ -252,29 +252,29 @@ class _Network(object): client implementations which use this test identifier. Do not use the identifiers used by other clients. ========= - + To obtain a new client identifier please contact: * Last.fm: submissions@last.fm * # TODO: list others - + ...and provide us with the name of your client and its homepage address. """ - + _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") - + return Scrobbler(self, client_id, client_version) - + def _get_language_domain(self, domain_language): """ Returns the mapped domain name of the network to a DOMAIN_* value """ - + if domain_language in self.domain_names: return self.domain_names[domain_language] - + def _get_url(self, domain, type): return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type]) - + def _get_ws_auth(self): """ Returns a (API_KEY, API_SECRET, SESSION_KEY) tuple. @@ -285,83 +285,83 @@ class _Network(object): """ Makes sure that web service calls are at least a second apart """ - + # delay time in seconds DELAY_TIME = 1.0 now = time.time() - + if (now - self.last_call_time) < DELAY_TIME: time.sleep(1) - + self.last_call_time = now - + def create_new_playlist(self, title, description): """ Creates a playlist for the authenticated user and returns it title: The title of the new playlist. description: The description of the new playlist. """ - + params = {} params['title'] = title params['description'] = description - + doc = _Request(self, 'playlist.create', params).execute(False) - + e_id = doc.getElementsByTagName("id")[0].firstChild.data user = doc.getElementsByTagName('playlists')[0].getAttribute('user') - + return Playlist(user, e_id, self) def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" - + doc = _Request(self, "tag.getTopTags").execute(True) seq = [] for node in doc.getElementsByTagName("tag"): tag = Tag(_extract(node, "name"), self) weight = _number(_extract(node, "count")) - + seq.append(TopItem(tag, weight)) - + if limit: seq = seq[:limit] - + return seq def enable_proxy(self, host, port): """Enable a default web proxy""" - + self.proxy = [host, _number(port)] self.proxy_enabled = True def disable_proxy(self): """Disable using the web proxy""" - + self.proxy_enabled = False def is_proxy_enabled(self): """Returns True if a web proxy is enabled.""" - + return self.proxy_enabled def _get_proxy(self): """Returns proxy details.""" - + return self.proxy - + def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - + * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ - + if not file_path: file_path = tempfile.mktemp(prefix="pylast_tmp_") - + self.cache_backend = _ShelfCacheBackend(file_path) - + def disable_caching(self): """Disables all caching features.""" @@ -369,36 +369,36 @@ class _Network(object): def is_caching_enabled(self): """Returns True if caching is enabled.""" - + return not (self.cache_backend == None) def _get_cache_backend(self): - + return self.cache_backend - + def search_for_album(self, album_name): """Searches for an album by its name. Returns a AlbumSearch object. Use get_next_page() to retreive sequences of results.""" - + return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): """Searches of an artist by its name. Returns a ArtistSearch object. Use get_next_page() to retreive sequences of results.""" - + return ArtistSearch(artist_name, self) def search_for_tag(self, tag_name): """Searches of a tag by its name. Returns a TagSearch object. Use get_next_page() to retreive sequences of results.""" - + return TagSearch(tag_name, self) def search_for_track(self, artist_name, track_name): """Searches of a track by its name and its artist. Set artist to an empty string if not available. Returns a TrackSearch object. Use get_next_page() to retreive sequences of results.""" - + return TrackSearch(artist_name, track_name, self) def search_for_venue(self, venue_name, country_name): @@ -407,39 +407,39 @@ class _Network(object): Use get_next_page() to retreive sequences of results.""" return VenueSearch(venue_name, country_name, self) - + def get_track_by_mbid(self, mbid): """Looks up a track by its MusicBrainz ID""" - + params = {"mbid": mbid} - + doc = _Request(self, "track.getInfo", params).execute(True) - + return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) def get_artist_by_mbid(self, mbid): """Loooks up an artist by its MusicBrainz ID""" - + params = {"mbid": mbid} - + doc = _Request(self, "artist.getInfo", params).execute(True) - + return Artist(_extract(doc, "name"), self) def get_album_by_mbid(self, mbid): """Looks up an album by its MusicBrainz ID""" - + params = {"mbid": mbid} - + doc = _Request(self, "album.getInfo", params).execute(True) - + return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - + def update_now_playing(self, artist, title, album = None, album_artist = None, duration = None, track_number = None, mbid = None, context = None): """ Used to notify Last.fm that a user has started listening to a track. - + Parameters: artist (Required) : The artist name title (Required) : The track title @@ -450,23 +450,23 @@ class _Network(object): mbid (Optional) : The MusicBrainz Track ID. context (Optional) : Sub-client version (not public, only enabled for certain API keys) """ - + params = {"track": title, "artist": artist} - + if album: params["album"] = album if album_artist: params["albumArtist"] = album_artist if context: params["context"] = context if track_number: params["trackNumber"] = track_number if mbid: params["mbid"] = mbid if duration: params["duration"] = duration - + _Request(self, "track.updateNowPlaying", params).execute() - + def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, duration = None, stream_id = None, context = None, mbid = None): - + """Used to add a track-play to a user's profile. - + Parameters: artist (Required) : The artist name. title (Required) : The track name. @@ -479,66 +479,66 @@ class _Network(object): mbid (Optional) : The MusicBrainz Track ID. duration (Optional) : The length of the track in seconds. """ - + return self.scrobble_many(({"artist": artist, "title": title, "timestamp": timestamp, "album": album, "album_artist": album_artist, "track_number": track_number, "duration": duration, "stream_id": stream_id, "context": context, "mbid": mbid},)) - + def scrobble_many(self, tracks): """ Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per track containing the keyword arguments as if passed to the scrobble() method. """ - + tracks_to_scrobble = tracks[:50] if len(tracks) > 50: remaining_tracks = tracks[50:] else: remaining_tracks = None - + params = {} for i in range(len(tracks_to_scrobble)): - + params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"] params["track[%d]" % i] = tracks_to_scrobble[i]["title"] - + additional_args = ("timestamp", "album", "album_artist", "context", "stream_id", "track_number", "mbid", "duration") args_map_to = {"album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID"} # so friggin lazy - + for arg in additional_args: - + if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]: if arg in args_map_to: maps_to = args_map_to[arg] else: maps_to = arg - + params["%s[%d]" %(maps_to, i)] = tracks_to_scrobble[i][arg] - - + + _Request(self, "track.scrobble", params).execute() - + if remaining_tracks: self.scrobble_many(remaining_tracks) - + class LastFMNetwork(_Network): - + """A Last.fm network object - + api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password - + if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. - + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - + Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: http://www.last.fm/api/account """ - + def __init__(self, api_key="", api_secret="", session_key="", username="", password_hash=""): _Network.__init__(self, name = "Last.fm", @@ -576,53 +576,53 @@ class LastFMNetwork(_Network): "user": "user/%(name)s", } ) - + def __repr__(self): return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) - + def __str__(self): return "LastFM Network" def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): """ Returns a preconfigured _Network object for Last.fm - + api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password - + if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. - + Either a valid session_key or a combination of username and password_hash must be present for scrobbling. - + Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: http://www.last.fm/api/account """ - + _deprecation_warning("Create a LastFMNetwork object instead") - + return LastFMNetwork(api_key, api_secret, session_key, username, password_hash) class LibreFMNetwork(_Network): """ A preconfigured _Network object for Libre.fm - + api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password - + if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. """ - + def __init__(self, api_key="", api_secret="", session_key = "", username = "", password_hash = ""): - + _Network.__init__(self, name = "Libre.fm", homepage = "http://alpha.dev.libre.fm", @@ -659,184 +659,184 @@ class LibreFMNetwork(_Network): "user": "user/%(name)s", } ) - + def __repr__(self): return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) - + def __str__(self): return "Libre.fm Network" def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): """ Returns a preconfigured _Network object for Libre.fm - + api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password - + if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. """ - + _deprecation_warning("DeprecationWarning: Create a LibreFMNetwork object instead") - + return LibreFMNetwork(api_key, api_secret, session_key, username, password_hash) class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" def __init__(self, file_path = None): self.shelf = shelve.open(file_path) - + def get_xml(self, key): return self.shelf[key] - + def set_xml(self, key, xml_string): self.shelf[key] = xml_string - + def has_key(self, key): return key in self.shelf.keys() - + class _Request(object): """Representing an abstract web service operation.""" - + def __init__(self, network, method_name, params = {}): - + self.network = network self.params = {} - + for key in params: self.params[key] = _unicode(params[key]) - + (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() - + self.params["api_key"] = self.api_key self.params["method"] = method_name - + if network.is_caching_enabled(): self.cache = network._get_cache_backend() - + if self.session_key: self.params["sk"] = self.session_key self.sign_it() def sign_it(self): """Sign this request.""" - + if not "api_sig" in self.params.keys(): self.params['api_sig'] = self._get_signature() - + def _get_signature(self): """Returns a 32-character hexadecimal md5 hash of the signature string.""" - + keys = list(self.params.keys()) - + keys.sort() - + string = "" - + for name in keys: string += name string += self.params[name] - + string += self.api_secret - + return md5(string) - + def _get_cache_key(self): """The cache key is a string of concatenated sorted names and values.""" - + keys = list(self.params.keys()) keys.sort() - + cache_key = str() - + for key in keys: if key != "api_sig" and key != "api_key" and key != "sk": cache_key += key + _string(self.params[key]) - + return hashlib.sha1(cache_key).hexdigest() - + def _get_cached_response(self): """Returns a file object of the cached response.""" - + if not self._is_cached(): response = self._download_response() self.cache.set_xml(self._get_cache_key(), response) - + return self.cache.get_xml(self._get_cache_key()) - + def _is_cached(self): """Returns True if the request is already in cache.""" - + return self.cache.has_key(self._get_cache_key()) - + def _download_response(self): """Returns a response body string from the server.""" - + # Delay the call if necessary #self.network._delay_call() # enable it if you want. - + data = [] for name in self.params.keys(): data.append('='.join((name, url_quote_plus(_string(self.params[name]))))) data = '&'.join(data) - + headers = { "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ } - + (HOST_NAME, HOST_SUBDIR) = self.network.ws_server - + if self.network.is_proxy_enabled(): conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) - + try: conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) - + else: conn = HTTPConnection(host=HOST_NAME) - + try: conn.request(method='POST', url=HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) - + try: response_text = _unicode(conn.getresponse().read()) except Exception as e: raise MalformedResponseError(self.network, e) - + self._check_response_for_errors(response_text) return response_text - + def execute(self, cacheable = False): """Returns the XML DOM response of the POST Request from the server""" - + if self.network.is_caching_enabled() and cacheable: response = self._get_cached_response() else: response = self._download_response() - + return minidom.parseString(_string(response)) - + def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" - + try: doc = minidom.parseString(_string(response)) except Exception as e: raise MalformedResponseError(self.network, e) - + e = doc.getElementsByTagName('lfm')[0] - + if e.getAttribute('status') != "ok": e = doc.getElementsByTagName('error')[0] status = e.getAttribute('code') @@ -856,76 +856,76 @@ class SessionKeyGenerator(object): b. username = raw_input("Please enter your username: ") c. password_hash = pylast.md5(raw_input("Please enter your password: ") d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) - + A session key's lifetime is infinie, unless the user provokes the rights of the given API Key. - + If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this manually, unless you want to. """ - + def __init__(self, network): self.network = network self.web_auth_tokens = {} - + def _get_web_auth_token(self): """Retrieves a token from the network for web authentication. The token then has to be authorized from getAuthURL before creating session. """ - + request = _Request(self.network, 'auth.getToken') - + # default action is that a request is signed only when # a session key is provided. request.sign_it() - + doc = request.execute() - + e = doc.getElementsByTagName('token')[0] return e.firstChild.data - + def get_web_auth_url(self): """The user must open this page, and you first, then call get_web_auth_session_key(url) after that.""" - + token = self._get_web_auth_token() - + url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ {"homepage": self.network.homepage, "api": self.network.api_key, "token": token} - + self.web_auth_tokens[url] = token - + return url def get_web_auth_session_key(self, url): """Retrieves the session key of a web authorization process by its url.""" - + if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] else: token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed. - + request = _Request(self.network, 'auth.getSession', {'token': token}) - + # default action is that a request is signed only when # a session key is provided. request.sign_it() - + doc = request.execute() - + return doc.getElementsByTagName('key')[0].firstChild.data - + def get_session_key(self, username, password_hash): """Retrieve a session key with a username and a md5 hash of the user's password.""" - + params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) - + # default action is that a request is signed only when # a session key is provided. request.sign_it() - + doc = request.execute() - + return _extract(doc, "key") TopItem = collections.namedtuple("TopItem", ["item", "weight"]) @@ -940,163 +940,163 @@ Shout = collections.namedtuple("Shout", ["body", "author", "date"]) def _string_output(funct): def r(*args): return _string(funct(*args)) - + return r def _pad_list(given_list, desired_length, padding = None): """ Pads a list to be of the desired_length. """ - + while len(given_list) < desired_length: given_list.append(padding) - + return given_list class _BaseObject(object): """An abstract webservices object.""" - + network = None - + def __init__(self, network): self.network = network - + def _request(self, method_name, cacheable = False, params = None): if not params: params = self._get_params() - + return _Request(self.network, method_name, params).execute(cacheable) - + def _get_params(self): """Returns the most common set of parameters between all objects.""" - + return {} - + def __hash__(self): return hash(self.network) + \ hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(self._get_params().values())).lower()) class _Taggable(object): """Common functions for classes with tags.""" - + def __init__(self, ws_prefix): self.ws_prefix = ws_prefix - + def add_tags(self, tags): """Adds one or several tags. * tags: A sequence of tag names or Tag objects. """ - + for tag in tags: self.add_tag(tag) - + def add_tag(self, tag): """Adds one tag. * tag: a tag name or a Tag object. """ - + if isinstance(tag, Tag): tag = tag.get_name() - + params = self._get_params() params['tags'] = tag - + self._request(self.ws_prefix + '.addTags', False, params) - + def remove_tag(self, tag): """Remove a user's tag from this object.""" - + if isinstance(tag, Tag): tag = tag.get_name() - + params = self._get_params() params['tag'] = tag - + self._request(self.ws_prefix + '.removeTag', False, params) def get_tags(self): """Returns a list of the tags set by the user to this object.""" - + # Uncacheable because it can be dynamically changed by the user. params = self._get_params() - + doc = self._request(self.ws_prefix + '.getTags', False, params) tag_names = _extract_all(doc, 'name') tags = [] for tag in tag_names: tags.append(Tag(tag, self.network)) - + return tags - + def remove_tags(self, tags): """Removes one or several tags from this object. * tags: a sequence of tag names or Tag objects. """ - + for tag in tags: self.remove_tag(tag) - + def clear_tags(self): """Clears all the user-set tags. """ - + self.remove_tags(*(self.get_tags())) - + def set_tags(self, tags): """Sets this object's tags to only those tags. * tags: a sequence of tag names or Tag objects. """ - + c_old_tags = [] old_tags = [] c_new_tags = [] new_tags = [] - + to_remove = [] to_add = [] - + tags_on_server = self.get_tags() - + for tag in tags_on_server: c_old_tags.append(tag.get_name().lower()) old_tags.append(tag.get_name()) - + for tag in tags: c_new_tags.append(tag.lower()) new_tags.append(tag) - + for i in range(0, len(old_tags)): if not c_old_tags[i] in c_new_tags: to_remove.append(old_tags[i]) - + for i in range(0, len(new_tags)): if not c_new_tags[i] in c_old_tags: to_add.append(new_tags[i]) - + self.remove_tags(to_remove) self.add_tags(to_add) - + def get_top_tags(self, limit=None): """Returns a list of the most frequently used Tags on this object.""" - + doc = self._request(self.ws_prefix + '.getTopTags', True) - + elements = doc.getElementsByTagName('tag') seq = [] - + for element in elements: tag_name = _extract(element, 'name') tagcount = _extract(element, 'count') - + seq.append(TopItem(Tag(tag_name, self.network), tagcount)) - + if limit: seq = seq[:limit] - + return seq - + class WSError(Exception): """Exception related to the Network web service""" - + def __init__(self, network, status, details): self.status = status self.details = details @@ -1105,7 +1105,7 @@ class WSError(Exception): @_string_output def __str__(self): return self.details - + def get_id(self): """Returns the exception ID, from one of the following: STATUS_INVALID_SERVICE = 2 @@ -1122,32 +1122,32 @@ class WSError(Exception): STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 """ - + return self.status class MalformedResponseError(Exception): """Exception conveying a malformed response from Last.fm.""" - + def __init__(self, network, underlying_error): self.network = network self.underlying_error = underlying_error - + def __str__(self): return "Malformed response from Last.fm. Underlying error: %s" %str(self.underlying_error) class NetworkError(Exception): """Exception conveying a problem in sending a request to Last.fm""" - + def __init__(self, network, underlying_error): self.network = network self.underlying_error = underlying_error - + def __str__(self): return "NetworkError: %s" %str(self.underlying_error) class Album(_BaseObject, _Taggable): """An album.""" - + title = None artist = None username = None @@ -1159,54 +1159,54 @@ class Album(_BaseObject, _Taggable): * artist: An artist name or an Artist object. * title: The album title. """ - + _BaseObject.__init__(self, network) _Taggable.__init__(self, 'album') - + if isinstance(artist, Artist): self.artist = artist else: self.artist = Artist(artist, self.network) - + self.title = title self.username = username - + def __repr__(self): return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @_string_output def __str__(self): return _unicode("%s - %s") %(self.get_artist().get_name(), self.get_title()) - + def __eq__(self, other): return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) - + def __ne__(self, other): return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) - + def _get_params(self): return {'artist': self.get_artist().get_name(), 'album': self.get_title(), } - + def get_artist(self): """Returns the associated Artist object.""" - + return self.artist - + def get_title(self): """Returns the album title.""" - + return self.title - + def get_name(self): """Returns the album title (alias to Album.get_title).""" - + return self.get_title() - + def get_release_date(self): """Retruns the release date of the album.""" - + return _extract(self._request("album.getInfo", cacheable = True), "releasedate") - + def get_cover_image(self, size = COVER_EXTRA_LARGE): """ Returns a uri to the cover image @@ -1216,19 +1216,19 @@ class Album(_BaseObject, _Taggable): COVER_MEDIUM COVER_SMALL """ - + return _extract_all(self._request("album.getInfo", cacheable = True), 'image')[size] - + def get_id(self): """Returns the ID""" - + return _extract(self._request("album.getInfo", cacheable = True), "id") - + def get_playcount(self): """Returns the number of plays on the network""" - + return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) - + def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1241,36 +1241,36 @@ class Album(_BaseObject, _Taggable): def get_listener_count(self): """Returns the number of liteners on the network""" - + return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) - + def get_top_tags(self, limit=None): """Returns a list of the most-applied tags to this album.""" - + doc = self._request("album.getInfo", True) e = doc.getElementsByTagName("toptags")[0] - + seq = [] for name in _extract_all(e, "name"): seq.append(Tag(name, self.network)) - + if limit: seq = seq[:limit] - + return seq def get_tracks(self): """Returns the list of Tracks on this album.""" - + uri = 'lastfm://playlist/album/%s' %self.get_id() - + return XSPF(uri, self.network).get_tracks() - + def get_mbid(self): """Returns the MusicBrainz id of the album.""" - + return _extract(self._request("album.getInfo", cacheable = True), "mbid") - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the album page on the network. # Parameters: @@ -1288,92 +1288,92 @@ class Album(_BaseObject, _Taggable): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + artist = _url_safe(self.get_artist().get_name()) album = _url_safe(self.get_title()) - + return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album} - + def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" - + doc = self._request("album.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "published") - + def get_wiki_summary(self): """Returns the summary of the wiki.""" - + doc = self._request("album.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "summary") - + def get_wiki_content(self): """Returns the content of the wiki.""" - + doc = self._request("album.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "content") class Artist(_BaseObject, _Taggable): """An artist.""" - + name = None username = None - + def __init__(self, name, network, username=None): """Create an artist object. # Parameters: * name str: The artist's name. """ - + _BaseObject.__init__(self, network) _Taggable.__init__(self, 'artist') - + self.name = name self.username = username - + def __repr__(self): return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) - + @_string_output def __str__(self): return self.get_name() - + def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() - + def __ne__(self, other): return self.get_name().lower() != other.get_name().lower() - + def _get_params(self): return {'artist': self.get_name()} - + def get_name(self, properly_capitalized=False): """Returns the name of the artist. If properly_capitalized was asserted then the name would be downloaded overwriting the given one.""" - + if properly_capitalized: self.name = _extract(self._request("artist.getInfo", True), "name") - + return self.name - + def get_cover_image(self, size = COVER_MEGA): """ Returns a uri to the cover image @@ -1384,12 +1384,12 @@ class Artist(_BaseObject, _Taggable): COVER_MEDIUM COVER_SMALL """ - + return _extract_all(self._request("artist.getInfo", True), "image")[size] - + def get_playcount(self): """Returns the number of plays on the network.""" - + return _number(_extract(self._request("artist.getInfo", True), "playcount")) def get_userplaycount(self): @@ -1404,30 +1404,30 @@ class Artist(_BaseObject, _Taggable): def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" - + doc = self._request("artist.getInfo", True) - + return _extract(doc, "mbid") - + def get_listener_count(self): """Returns the number of liteners on the network.""" - + if hasattr(self, "listener_count"): return self.listener_count else: self.listener_count = _number(_extract(self._request("artist.getInfo", True), "listeners")) return self.listener_count - + def is_streamable(self): """Returns True if the artist is streamable.""" - + return bool(_number(_extract(self._request("artist.getInfo", True), "streamable"))) - + def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" - + return _extract(self._request("artist.getInfo", True), "published") - + def get_bio_summary(self, language=None): """Returns the summary of the artist's biography.""" @@ -1436,104 +1436,104 @@ class Artist(_BaseObject, _Taggable): params["lang"] = language else: params = None - + return _extract(self._request("artist.getInfo", True, params), "summary") - + def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" - + if language: params = self._get_params() params["lang"] = language else: params = None - + return _extract(self._request("artist.getInfo", True, params), "content") - + def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" - + doc = self._request('artist.getEvents', True) - + ids = _extract_all(doc, 'id') - + events = [] for e_id in ids: events.append(Event(e_id, self.network)) - + return events - + def get_similar(self, limit = None): """Returns the similar artists on the network.""" - + params = self._get_params() if limit: params['limit'] = limit - + doc = self._request('artist.getSimilar', True, params) - + names = _extract_all(doc, "name") matches = _extract_all(doc, "match") - + artists = [] for i in range(0, len(names)): artists.append(SimilarItem(Artist(names[i], self.network), _number(matches[i]))) - + return artists def get_top_albums(self): """Retuns a list of the top albums.""" - + doc = self._request('artist.getTopAlbums', True) - + seq = [] - + for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") - + seq.append(TopItem(Album(artist, name, self.network), playcount)) - + return seq - + def get_top_tracks(self): """Returns a list of the most played Tracks by this artist.""" - + doc = self._request("artist.getTopTracks", True) - + seq = [] for track in doc.getElementsByTagName('track'): - + title = _extract(track, "name") artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) - + seq.append( TopItem(Track(artist, title, self.network), playcount) ) - + return seq - + def get_top_fans(self, limit = None): """Returns a list of the Users who played this artist the most. # Parameters: * limit int: Max elements. """ - + doc = self._request('artist.getTopFans', True) - + seq = [] - + elements = doc.getElementsByTagName('user') - + for element in elements: if limit and len(seq) >= limit: break - + name = _extract(element, 'name') weight = _number(_extract(element, 'weight')) - + seq.append(TopItem(User(name, self.network), weight)) - + return seq def share(self, users, message = None): @@ -1542,28 +1542,28 @@ class Artist(_BaseObject, _Taggable): * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. * message str: A message to include in the recommendation message. """ - + #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) - + nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) - + params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = message - + self._request('artist.share', False, params) - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the artist page on the network. # Parameters: @@ -1581,22 +1581,22 @@ class Artist(_BaseObject, _Taggable): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + artist = _url_safe(self.get_name()) - + return self.network._get_url(domain_name, "artist") %{'artist': artist} - + def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): """ Returns a sequence of Image objects if limit is None it will return all order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE. - + If limit==None, it will try to pull all the available data. """ - + images = [] - + params = self._get_params() params["order"] = order nodes = _collect_nodes(limit, self, "artist.getImages", True, params) @@ -1605,7 +1605,7 @@ class Artist(_BaseObject, _Taggable): user = User(_extract(e, "name"), self.network) else: user = None - + images.append(Image( _extract(e, "title"), _extract(e, "url"), @@ -1622,7 +1622,7 @@ class Artist(_BaseObject, _Taggable): """ Returns a sequqence of Shout objects """ - + shouts = [] for node in _collect_nodes(limit, self, "artist.getShouts", False): shouts.append(Shout( @@ -1632,44 +1632,44 @@ class Artist(_BaseObject, _Taggable): ) ) return shouts - + def shout(self, message): """ Post a shout """ - + params = self._get_params() params["message"] = message - + self._request("artist.Shout", False, params) - + class Event(_BaseObject): """An event.""" - + id = None - + def __init__(self, event_id, network): _BaseObject.__init__(self, network) - + self.id = event_id - + def __repr__(self): return "pylast.Event(%s, %s)" %(repr(self.id), repr(self.network)) - + @_string_output def __str__(self): return "Event #" + self.get_id() - + def __eq__(self, other): return self.get_id() == other.get_id() - + def __ne__(self, other): return self.get_id() != other.get_id() - + def _get_params(self): return {'event': self.get_id()} - + def attend(self, attending_status): """Sets the attending status. * attending_status: The attending status. Possible values: @@ -1677,80 +1677,80 @@ class Event(_BaseObject): o EVENT_MAYBE_ATTENDING o EVENT_NOT_ATTENDING """ - + params = self._get_params() params['status'] = attending_status - + self._request('event.attend', False, params) - + def get_attendees(self): """ Get a list of attendees for an event """ - + doc = self._request("event.getAttendees", False) - + users = [] for name in _extract_all(doc, "name"): users.append(User(name, self.network)) - + return users - + def get_id(self): """Returns the id of the event on the network. """ - + return self.id - + def get_title(self): """Returns the title of the event. """ - + doc = self._request("event.getInfo", True) - + return _extract(doc, "title") - + def get_headliner(self): """Returns the headliner of the event. """ - + doc = self._request("event.getInfo", True) - + return Artist(_extract(doc, "headliner"), self.network) - + def get_artists(self): """Returns a list of the participating Artists. """ - + doc = self._request("event.getInfo", True) names = _extract_all(doc, "artist") - + artists = [] for name in names: artists.append(Artist(name, self.network)) - + return artists - + def get_venue(self): """Returns the venue where the event is held.""" - + doc = self._request("event.getInfo", True) - + v = doc.getElementsByTagName("venue")[0] venue_id = _number(_extract(v, "id")) - + return Venue(venue_id, self.network) - + def get_start_date(self): """Returns the date when the event starts.""" - + doc = self._request("event.getInfo", True) - + return _extract(doc, "startDate") - + def get_description(self): """Returns the description of the event. """ - + doc = self._request("event.getInfo", True) - + return _extract(doc, "description") - + def get_cover_image(self, size = COVER_MEGA): """ Returns a uri to the cover image @@ -1761,25 +1761,25 @@ class Event(_BaseObject): COVER_MEDIUM COVER_SMALL """ - + doc = self._request("event.getInfo", True) - + return _extract_all(doc, "image")[size] - + def get_attendance_count(self): """Returns the number of attending people. """ - + doc = self._request("event.getInfo", True) - + return _number(_extract(doc, "attendance")) - + def get_review_count(self): """Returns the number of available reviews for this event. """ - + doc = self._request("event.getInfo", True) - + return _number(_extract(doc, "reviews")) - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: @@ -1796,7 +1796,7 @@ class Event(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + return self.network._get_url(domain_name, "event") %{'id': self.get_id()} def share(self, users, message = None): @@ -1804,33 +1804,33 @@ class Event(_BaseObject): * users: A list that can contain usernames, emails, User objects, or all of them. * message: A message to include in the recommendation message. """ - + #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) - + nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) - + params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = message - + self._request('event.share', False, params) def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ - + shouts = [] for node in _collect_nodes(limit, self, "event.getShouts", False): shouts.append(Shout( @@ -1840,84 +1840,84 @@ class Event(_BaseObject): ) ) return shouts - + def shout(self, message): """ Post a shout """ - + params = self._get_params() params["message"] = message - + self._request("event.Shout", False, params) - + class Country(_BaseObject): """A country at Last.fm.""" - + name = None - + def __init__(self, name, network): _BaseObject.__init__(self, network) - + self.name = name - + def __repr__(self): return "pylast.Country(%s, %s)" %(repr(self.name), repr(self.network)) - + @_string_output def __str__(self): return self.get_name() - + def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() - + def __ne__(self, other): return self.get_name() != other.get_name() - + def _get_params(self): return {'country': self.get_name()} - + def _get_name_from_code(self, alpha2code): # TODO: Have this function lookup the alpha-2 code and return the country name. - + return alpha2code - + def get_name(self): """Returns the country name. """ - + return self.name - + def get_top_artists(self): """Returns a sequence of the most played artists.""" - + doc = self._request('geo.getTopArtists', True) - + seq = [] for node in doc.getElementsByTagName("artist"): name = _extract(node, 'name') playcount = _extract(node, "playcount") - + seq.append(TopItem(Artist(name, self.network), playcount)) - + return seq - + def get_top_tracks(self): """Returns a sequence of the most played tracks""" - + doc = self._request("geo.getTopTracks", True) - + seq = [] - + for n in doc.getElementsByTagName('track'): - + title = _extract(n, 'name') artist = _extract(n, 'name', 1) playcount = _number(_extract(n, "playcount")) - + seq.append( TopItem(Track(artist, title, self.network), playcount)) - + return seq - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: @@ -1934,106 +1934,106 @@ class Country(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + country_name = _url_safe(self.get_name()) - + return self.network._get_url(domain_name, "country") %{'country_name': country_name} class Library(_BaseObject): """A user's Last.fm library.""" - + user = None - + def __init__(self, user, network): _BaseObject.__init__(self, network) - + if isinstance(user, User): self.user = user else: self.user = User(user, self.network) - + self._albums_index = 0 self._artists_index = 0 self._tracks_index = 0 - + def __repr__(self): return "pylast.Library(%s, %s)" %(repr(self.user), repr(self.network)) - + @_string_output def __str__(self): return repr(self.get_user()) + "'s Library" - + def _get_params(self): return {'user': self.user.get_name()} - + def get_user(self): """Returns the user who owns this library.""" - + return self.user - + def add_album(self, album): """Add an album to this library.""" - + params = self._get_params() params["artist"] = album.get_artist.get_name() params["album"] = album.get_name() - + self._request("library.addAlbum", False, params) - + def add_artist(self, artist): """Add an artist to this library.""" - + params = self._get_params() params["artist"] = artist.get_name() - + self._request("library.addArtist", False, params) - + def add_track(self, track): """Add a track to this library.""" - + params = self._get_params() params["track"] = track.get_title() - + self._request("library.addTrack", False, params) - + def get_albums(self, artist=None, limit=50): """ Returns a sequence of Album objects If no artist is specified, it will return all, sorted by playcount descendingly. If limit==None it will return all (may take a while) """ - + params = self._get_params() if artist: params["artist"] = artist - + seq = [] for node in _collect_nodes(limit, self, "library.getAlbums", True, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - + seq.append(LibraryItem(Album(artist, name, self.network), playcount, tagcount)) - + return seq - + def get_artists(self, limit=50): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ - + seq = [] for node in _collect_nodes(limit, self, "library.getArtists", True): name = _extract(node, "name") - + playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - + seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) - + return seq def get_tracks(self, artist=None, album=None, limit=50): @@ -2041,122 +2041,122 @@ class Library(_BaseObject): Returns a sequence of Album objects If limit==None it will return all (may take a while) """ - + params = self._get_params() if artist: params["artist"] = artist if album: params["album"] = album - + seq = [] for node in _collect_nodes(limit, self, "library.getTracks", True, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - + seq.append(LibraryItem(Track(artist, name, self.network), playcount, tagcount)) - + return seq - + class Playlist(_BaseObject): """A Last.fm user playlist.""" - + id = None user = None - + def __init__(self, user, id, network): _BaseObject.__init__(self, network) - + if isinstance(user, User): self.user = user else: self.user = User(user, self.network) - + self.id = id - + @_string_output def __str__(self): return repr(self.user) + "'s playlist # " + repr(self.id) - + def _get_info_node(self): """Returns the node from user.getPlaylists where this playlist's info is.""" - + doc = self._request("user.getPlaylists", True) - + for node in doc.getElementsByTagName("playlist"): if _extract(node, "id") == str(self.get_id()): return node - + def _get_params(self): return {'user': self.user.get_name(), 'playlistID': self.get_id()} - + def get_id(self): """Returns the playlist id.""" - + return self.id - + def get_user(self): """Returns the owner user of this playlist.""" - + return self.user - + def get_tracks(self): """Returns a list of the tracks on this user playlist.""" - + uri = _unicode('lastfm://playlist/%s') %self.get_id() - + return XSPF(uri, self.network).get_tracks() - + def add_track(self, track): """Adds a Track to this Playlist.""" - + params = self._get_params() params['artist'] = track.get_artist().get_name() params['track'] = track.get_title() - + self._request('playlist.addTrack', False, params) - + def get_title(self): """Returns the title of this playlist.""" - + return _extract(self._get_info_node(), "title") - + def get_creation_date(self): """Returns the creation date of this playlist.""" - + return _extract(self._get_info_node(), "date") - + def get_size(self): """Returns the number of tracks in this playlist.""" - + return _number(_extract(self._get_info_node(), "size")) - + def get_description(self): """Returns the description of this playlist.""" - + return _extract(self._get_info_node(), "description") - + def get_duration(self): """Returns the duration of this playlist in milliseconds.""" - + return _number(_extract(self._get_info_node(), "duration")) - + def is_streamable(self): """Returns True if the playlist is streamable. For a playlist to be streamable, it needs at least 45 tracks by 15 different artists.""" - + if _extract(self._get_info_node(), "streamable") == '1': return True else: return False - + def has_track(self, track): """Checks to see if track is already in the playlist. * track: Any Track object. """ - + return track in self.get_tracks() def get_cover_image(self, size = COVER_EXTRA_LARGE): @@ -2169,9 +2169,9 @@ class Playlist(_BaseObject): COVER_MEDIUM COVER_SMALL """ - + return _extract(self._get_info_node(), "image")[size] - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: @@ -2188,136 +2188,136 @@ class Playlist(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + english_url = _extract(self._get_info_node(), "url") appendix = english_url[english_url.rfind("/") + 1:] - + return self.network._get_url(domain_name, "playlist") %{'appendix': appendix, "user": self.get_user().get_name()} - + class Tag(_BaseObject): """A Last.fm object tag.""" - + # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it) - + name = None - + def __init__(self, name, network): _BaseObject.__init__(self, network) - + self.name = name - + def __repr__(self): return "pylast.Tag(%s, %s)" %(repr(self.name), repr(self.network)) - + @_string_output def __str__(self): return self.get_name() - + def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() - + def __ne__(self, other): return self.get_name().lower() != other.get_name().lower() - + def _get_params(self): return {'tag': self.get_name()} - + def get_name(self, properly_capitalized=False): """Returns the name of the tag. """ - + if properly_capitalized: self.name = _extract(self._request("tag.getInfo", True), "name") - + return self.name def get_similar(self): """Returns the tags similar to this one, ordered by similarity. """ - + doc = self._request('tag.getSimilar', True) - + seq = [] names = _extract_all(doc, 'name') for name in names: seq.append(Tag(name, self.network)) - + return seq - + def get_top_albums(self): """Retuns a list of the top albums.""" - + doc = self._request('tag.getTopAlbums', True) - + seq = [] - + for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") - + seq.append(TopItem(Album(artist, name, self.network), playcount)) - + return seq - + def get_top_tracks(self): """Returns a list of the most played Tracks by this artist.""" - + doc = self._request("tag.getTopTracks", True) - + seq = [] for track in doc.getElementsByTagName('track'): - + title = _extract(track, "name") artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) - + seq.append( TopItem(Track(artist, title, self.network), playcount) ) - + return seq - + def get_top_artists(self): """Returns a sequence of the most played artists.""" - + doc = self._request('tag.getTopArtists', True) - + seq = [] for node in doc.getElementsByTagName("artist"): name = _extract(node, 'name') playcount = _extract(node, "playcount") - + seq.append(TopItem(Artist(name, self.network), playcount)) - + return seq - + def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" - + doc = self._request("tag.getWeeklyChartList", True) - + seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - + return seq - + def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("tag.getWeeklyArtistChart", True, params) - + seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "weight")) seq.append(TopItem(item, weight)) - + return seq - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: @@ -2334,14 +2334,14 @@ class Tag(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + name = _url_safe(self.get_name()) - + return self.network._get_url(domain_name, "tag") %{'name': name} class Track(_BaseObject, _Taggable): """A Last.fm track.""" - + artist = None title = None username = None @@ -2349,16 +2349,16 @@ class Track(_BaseObject, _Taggable): def __init__(self, artist, title, network, username=None): _BaseObject.__init__(self, network) _Taggable.__init__(self, 'track') - + if isinstance(artist, Artist): self.artist = artist else: self.artist = Artist(artist, self.network) - + self.title = title self.username = username - + def __repr__(self): return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -2368,65 +2368,65 @@ class Track(_BaseObject, _Taggable): def __eq__(self, other): return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) - + def __ne__(self, other): return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) - + def _get_params(self): return {'artist': self.get_artist().get_name(), 'track': self.get_title()} - + def get_artist(self): """Returns the associated Artist object.""" - + return self.artist - + def get_title(self, properly_capitalized=False): """Returns the track title.""" - + if properly_capitalized: self.title = _extract(self._request("track.getInfo", True), "name") - + return self.title - + def get_name(self, properly_capitalized=False): """Returns the track title (alias to Track.get_title).""" - + return self.get_title(properly_capitalized) - + def get_id(self): """Returns the track id on the network.""" - + doc = self._request("track.getInfo", True) - + return _extract(doc, "id") - + def get_duration(self): """Returns the track duration.""" - + doc = self._request("track.getInfo", True) - + return _number(_extract(doc, "duration")) - + def get_mbid(self): """Returns the MusicBrainz ID of this track.""" - + doc = self._request("track.getInfo", True) - + return _extract(doc, "mbid") - + def get_listener_count(self): """Returns the listener count.""" - + if hasattr(self, "listener_count"): return self.listener_count else: doc = self._request("track.getInfo", True) self.listener_count = _number(_extract(doc, "listeners")) return self.listener_count - + def get_playcount(self): """Returns the play count.""" - + doc = self._request("track.getInfo", True) return _number(_extract(doc, "playcount")) @@ -2440,140 +2440,140 @@ class Track(_BaseObject, _Taggable): doc = self._request("track.getInfo", True, params) return _number(_extract(doc, "userplaycount")) - + def is_streamable(self): """Returns True if the track is available at Last.fm.""" - + doc = self._request("track.getInfo", True) return _extract(doc, "streamable") == "1" - + def is_fulltrack_available(self): """Returns True if the fulltrack is available for streaming.""" - + doc = self._request("track.getInfo", True) return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" - + def get_album(self): """Returns the album object of this track.""" - + doc = self._request("track.getInfo", True) - + albums = doc.getElementsByTagName("album") - + if len(albums) == 0: return - + node = doc.getElementsByTagName("album")[0] return Album(_extract(node, "artist"), _extract(node, "title"), self.network) - + def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" - + doc = self._request("track.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "published") - + def get_wiki_summary(self): """Returns the summary of the wiki.""" - + doc = self._request("track.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "summary") - + def get_wiki_content(self): """Returns the content of the wiki.""" - + doc = self._request("track.getInfo", True) - + if len(doc.getElementsByTagName("wiki")) == 0: return - + node = doc.getElementsByTagName("wiki")[0] - + return _extract(node, "content") - + def love(self): """Adds the track to the user's loved tracks. """ - + self._request('track.love') - + def ban(self): """Ban this track from ever playing on the radio. """ - + self._request('track.ban') - + def get_similar(self): """Returns similar tracks for this track on the network, based on listening data. """ - + doc = self._request('track.getSimilar', True) - + seq = [] for node in doc.getElementsByTagName("track"): title = _extract(node, 'name') artist = _extract(node, 'name', 1) match = _number(_extract(node, "match")) - + seq.append(SimilarItem(Track(artist, title, self.network), match)) - + return seq def get_top_fans(self, limit = None): """Returns a list of the Users who played this track.""" - + doc = self._request('track.getTopFans', True) - + seq = [] - + elements = doc.getElementsByTagName('user') - + for element in elements: if limit and len(seq) >= limit: break - + name = _extract(element, 'name') weight = _number(_extract(element, 'weight')) - + seq.append(TopItem(User(name, self.network), weight)) - + return seq - + def share(self, users, message = None): """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. * message: A message to include in the recommendation message. """ - + #last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] self.share(section, message) - + nusers = [] for user in users: if isinstance(user, User): nusers.append(user.get_name()) else: nusers.append(user) - + params = self._get_params() recipients = ','.join(nusers) params['recipient'] = recipients if message: params['message'] = message - + self._request('track.share', False, params) - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: @@ -2590,17 +2590,17 @@ class Track(_BaseObject, _Taggable): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) - + return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} - + def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ - + shouts = [] for node in _collect_nodes(limit, self, "track.getShouts", False): shouts.append(Shout( @@ -2610,102 +2610,102 @@ class Track(_BaseObject, _Taggable): ) ) return shouts - + class Group(_BaseObject): """A Last.fm group.""" - + name = None - + def __init__(self, group_name, network): _BaseObject.__init__(self, network) - + self.name = group_name - + def __repr__(self): return "pylast.Group(%s, %s)" %(repr(self.name), repr(self.network)) - + @_string_output def __str__(self): return self.get_name() - + def __eq__(self, other): return self.get_name().lower() == other.get_name().lower() - + def __ne__(self, other): return self.get_name() != other.get_name() - + def _get_params(self): return {'group': self.get_name()} - + def get_name(self): """Returns the group name. """ return self.name - + def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" - + doc = self._request("group.getWeeklyChartList", True) - + seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - + return seq - + def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("group.getWeeklyArtistChart", True, params) - + seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq def get_weekly_album_charts(self, from_date = None, to_date = None): """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("group.getWeeklyAlbumChart", True, params) - + seq = [] for node in doc.getElementsByTagName("album"): item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq def get_weekly_track_charts(self, from_date = None, to_date = None): """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("group.getWeeklyTrackChart", True, params) - + seq = [] for node in doc.getElementsByTagName("track"): item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: @@ -2722,302 +2722,302 @@ class Group(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + name = _url_safe(self.get_name()) - + return self.network._get_url(domain_name, "group") %{'name': name} - + def get_members(self, limit=50): """ Returns a sequence of User objects if limit==None it will return all """ - + nodes = _collect_nodes(limit, self, "group.getMembers", False) - + users = [] - + for node in nodes: users.append(User(_extract(node, "name"), self.network)) - + return users class XSPF(_BaseObject): "A Last.fm XSPF playlist.""" - + uri = None - + def __init__(self, uri, network): _BaseObject.__init__(self, network) - + self.uri = uri - + def _get_params(self): return {'playlistURL': self.get_uri()} - + @_string_output def __str__(self): return self.get_uri() - + def __eq__(self, other): return self.get_uri() == other.get_uri() - + def __ne__(self, other): return self.get_uri() != other.get_uri() - + def get_uri(self): """Returns the Last.fm playlist URI. """ - + return self.uri - + def get_tracks(self): """Returns the tracks on this playlist.""" - + doc = self._request('playlist.fetch', True) - + seq = [] for n in doc.getElementsByTagName('track'): title = _extract(n, 'title') artist = _extract(n, 'creator') - + seq.append(Track(artist, title, self.network)) - + return seq class User(_BaseObject): """A Last.fm user.""" - + name = None - + def __init__(self, user_name, network): _BaseObject.__init__(self, network) - + self.name = user_name - + self._past_events_index = 0 self._recommended_events_index = 0 self._recommended_artists_index = 0 - + def __repr__(self): return "pylast.User(%s, %s)" %(repr(self.name), repr(self.network)) - + @_string_output def __str__(self): return self.get_name() - + def __eq__(self, another): return self.get_name() == another.get_name() - + def __ne__(self, another): return self.get_name() != another.get_name() - + def _get_params(self): return {"user": self.get_name()} - + def get_name(self, properly_capitalized=False): """Returns the nuser name.""" - + if properly_capitalized: self.name = _extract(self._request("user.getInfo", True), "name") - + return self.name - + def get_upcoming_events(self): """Returns all the upcoming events for this user. """ - + doc = self._request('user.getEvents', True) - + ids = _extract_all(doc, 'id') events = [] - + for e_id in ids: events.append(Event(e_id, self.network)) - + return events - + def get_friends(self, limit = 50): """Returns a list of the user's friends. """ - + seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) - + return seq - + def get_loved_tracks(self, limit=50): """Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. - + If limit==None, it will try to pull all the available data. - + This method uses caching. Enable caching only if you're pulling a large amount of data. - + Use extract_items() with the return of this function to get only a sequence of Track objects with no playback dates. """ - + params = self._get_params() if limit: params['limit'] = limit - + seq = [] for track in _collect_nodes(limit, self, "user.getLovedTracks", True, params): - + title = _extract(track, "name") artist = _extract(track, "name", 1) date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - + seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) - + return seq - + def get_neighbours(self, limit = 50): """Returns a list of the user's friends.""" - + params = self._get_params() if limit: params['limit'] = limit - + doc = self._request('user.getNeighbours', True, params) - + seq = [] names = _extract_all(doc, 'name') - + for name in names: seq.append(User(name, self.network)) - + return seq - + def get_past_events(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ - + seq = [] for n in _collect_nodes(limit, self, "user.getPastEvents", False): seq.append(Event(_extract(n, "id"), self.network)) return seq - + def get_playlists(self): """Returns a list of Playlists that this user owns.""" - + doc = self._request("user.getPlaylists", True) - + playlists = [] for playlist_id in _extract_all(doc, "id"): playlists.append(Playlist(self.get_name(), playlist_id, self.network)) - + return playlists - + def get_now_playing(self): """Returns the currently playing track, or None if nothing is playing. """ - + params = self._get_params() params['limit'] = '1' - + doc = self._request('user.getRecentTracks', False, params) - + e = doc.getElementsByTagName('track')[0] - + if not e.hasAttribute('nowplaying'): return None - + artist = _extract(e, 'artist') title = _extract(e, 'name') - + return Track(artist, title, self.network, self.name) def get_recent_tracks(self, limit = 10): """Returns this user's played track as a sequence of PlayedTrack objects in reverse order of their playtime, all the way back to the first track. - + If limit==None, it will try to pull all the available data. - + This method uses caching. Enable caching only if you're pulling a large amount of data. - + Use extract_items() with the return of this function to get only a sequence of Track objects with no playback dates. """ - + params = self._get_params() if limit: params['limit'] = limit - + seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): - + if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here - + title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) - + return seq - + def get_id(self): """Returns the user id.""" - + doc = self._request("user.getInfo", True) - + return _extract(doc, "id") - + def get_language(self): """Returns the language code of the language used by the user.""" - + doc = self._request("user.getInfo", True) - + return _extract(doc, "lang") - + def get_country(self): """Returns the name of the country of the user.""" - + doc = self._request("user.getInfo", True) - + return Country(_extract(doc, "country"), self.network) - + def get_age(self): """Returns the user's age.""" - + doc = self._request("user.getInfo", True) - + return _number(_extract(doc, "age")) - + def get_gender(self): """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" - + doc = self._request("user.getInfo", True) - + value = _extract(doc, "gender") - + if value == 'm': return USER_MALE elif value == 'f': return USER_FEMALE - + return None - + def is_subscriber(self): """Returns whether the user is a subscriber or not. True or False.""" - + doc = self._request("user.getInfo", True) - + return _extract(doc, "subscriber") == "1" - + def get_playcount(self): """Returns the user's playcount so far.""" - + doc = self._request("user.getInfo", True) - + return _number(_extract(doc, "playcount")) - + def get_top_albums(self, period = PERIOD_OVERALL): """Returns the top albums played by a user. * period: The period of time. Possible values: @@ -3027,22 +3027,22 @@ class User(_BaseObject): o PERIOD_6MONTHS o PERIOD_12MONTHS """ - + params = self._get_params() params['period'] = period - + doc = self._request('user.getTopAlbums', True, params) - + seq = [] for album in doc.getElementsByTagName('album'): name = _extract(album, 'name') artist = _extract(album, 'name', 1) playcount = _extract(album, "playcount") - + seq.append(TopItem(Album(artist, name, self.network), playcount)) - + return seq - + def get_top_artists(self, period = PERIOD_OVERALL): """Returns the top artists played by a user. * period: The period of time. Possible values: @@ -3052,37 +3052,37 @@ class User(_BaseObject): o PERIOD_6MONTHS o PERIOD_12MONTHS """ - + params = self._get_params() params['period'] = period - + doc = self._request('user.getTopArtists', True, params) - + seq = [] for node in doc.getElementsByTagName('artist'): name = _extract(node, 'name') playcount = _extract(node, "playcount") - + seq.append(TopItem(Artist(name, self.network), playcount)) - + return seq - + def get_top_tags(self, limit=None): """Returns a sequence of the top tags used by this user with their counts as TopItem objects. * limit: The limit of how many tags to return. """ - + doc = self._request("user.getTopTags", True) - + seq = [] for node in doc.getElementsByTagName("tag"): seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) - + if limit: seq = seq[:limit] - + return seq - + def get_top_tracks(self, period = PERIOD_OVERALL): """Returns the top tracks played by a user. * period: The period of time. Possible values: @@ -3092,96 +3092,96 @@ class User(_BaseObject): o PERIOD_6MONTHS o PERIOD_12MONTHS """ - + params = self._get_params() params['period'] = period - + doc = self._request('user.getTopTracks', True, params) - + seq = [] for track in doc.getElementsByTagName('track'): name = _extract(track, 'name') artist = _extract(track, 'name', 1) playcount = _extract(track, "playcount") - + seq.append(TopItem(Track(artist, name, self.network), playcount)) - + return seq - + def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" - + doc = self._request("user.getWeeklyChartList", True) - + seq = [] for node in doc.getElementsByTagName("chart"): seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) - + return seq - + def get_weekly_artist_charts(self, from_date = None, to_date = None): """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("user.getWeeklyArtistChart", True, params) - + seq = [] for node in doc.getElementsByTagName("artist"): item = Artist(_extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq def get_weekly_album_charts(self, from_date = None, to_date = None): """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("user.getWeeklyAlbumChart", True, params) - + seq = [] for node in doc.getElementsByTagName("album"): item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq def get_weekly_track_charts(self, from_date = None, to_date = None): """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" - + params = self._get_params() if from_date and to_date: params["from"] = from_date params["to"] = to_date - + doc = self._request("user.getWeeklyTrackChart", True, params) - + seq = [] for node in doc.getElementsByTagName("track"): item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) - + return seq - + def compare_with_user(self, user, shared_artists_limit = None): """Compare this user with another Last.fm user. Returns a sequence (tasteometer_score, (shared_artist1, shared_artist2, ...)) user: A User object or a username string/unicode object. """ - + if isinstance(user, User): user = user.get_name() - + params = self._get_params() if shared_artists_limit: params['limit'] = shared_artists_limit @@ -3189,28 +3189,28 @@ class User(_BaseObject): params['type2'] = 'user' params['value1'] = self.get_name() params['value2'] = user - + doc = self._request('tasteometer.compare', False, params) - + score = _extract(doc, 'score') - + artists = doc.getElementsByTagName('artists')[0] shared_artists_names = _extract_all(artists, 'name') - + shared_artists_seq = [] - + for name in shared_artists_names: shared_artists_seq.append(Artist(name, self.network)) - + return (score, shared_artists_seq) - + def get_image(self): """Returns the user's avatar.""" - + doc = self._request("user.getInfo", True) - + return _extract(doc, "image") - + def get_url(self, domain_name = DOMAIN_ENGLISH): """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: @@ -3227,21 +3227,21 @@ class User(_BaseObject): o DOMAIN_JAPANESE o DOMAIN_CHINESE """ - + name = _url_safe(self.get_name()) - + return self.network._get_url(domain_name, "user") %{'name': name} def get_library(self): """Returns the associated Library object. """ - + return Library(self, self.network) def get_shouts(self, limit=50): """ Returns a sequqence of Shout objects """ - + shouts = [] for node in _collect_nodes(limit, self, "user.getShouts", False): shouts.append(Shout( @@ -3251,250 +3251,250 @@ class User(_BaseObject): ) ) return shouts - + def shout(self, message): """ Post a shout """ - + params = self._get_params() params["message"] = message - + self._request("user.Shout", False, params) class AuthenticatedUser(User): def __init__(self, network): User.__init__(self, "", network); - + def _get_params(self): return {"user": self.get_name()} - + def get_name(self): """Returns the name of the authenticated user.""" - + doc = self._request("user.getInfo", True, {"user": ""}) # hack - + self.name = _extract(doc, "name") return self.name - + def get_recommended_events(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ - + seq = [] for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False): seq.append(Event(_extract(node, "id"), self.network)) - + return seq - + def get_recommended_artists(self, limit=50): """ Returns a sequence of Event objects if limit==None it will return all """ - + seq = [] for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False): seq.append(Artist(_extract(node, "name"), self.network)) - + return seq - + class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" - + def __init__(self, ws_prefix, search_terms, network): _BaseObject.__init__(self, network) - + self._ws_prefix = ws_prefix self.search_terms = search_terms - + self._last_page_index = 0 - + def _get_params(self): params = {} - + for key in self.search_terms.keys(): params[key] = self.search_terms[key] - + return params - + def get_total_result_count(self): """Returns the total count of all the results.""" - + doc = self._request(self._ws_prefix + ".search", True) - + return _extract(doc, "opensearch:totalResults") - + def _retreive_page(self, page_index): """Returns the node of matches to be processed""" - + params = self._get_params() params["page"] = str(page_index) doc = self._request(self._ws_prefix + ".search", True, params) - + return doc.getElementsByTagName(self._ws_prefix + "matches")[0] - + def _retrieve_next_page(self): self._last_page_index += 1 return self._retreive_page(self._last_page_index) class AlbumSearch(_Search): """Search for an album by name.""" - + def __init__(self, album_name, network): - + _Search.__init__(self, "album", {"album": album_name}, network) - + def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" - + master_node = self._retrieve_next_page() - + seq = [] for node in master_node.getElementsByTagName("album"): seq.append(Album(_extract(node, "artist"), _extract(node, "name"), self.network)) - + return seq class ArtistSearch(_Search): """Search for an artist by artist name.""" - + def __init__(self, artist_name, network): _Search.__init__(self, "artist", {"artist": artist_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" - + master_node = self._retrieve_next_page() - + seq = [] for node in master_node.getElementsByTagName("artist"): artist = Artist(_extract(node, "name"), self.network) artist.listener_count = _number(_extract(node, "listeners")) seq.append(artist) - + return seq class TagSearch(_Search): """Search for a tag by tag name.""" - + def __init__(self, tag_name, network): - + _Search.__init__(self, "tag", {"tag": tag_name}, network) - + def get_next_page(self): """Returns the next page of results as a sequence of Tag objects.""" - + master_node = self._retrieve_next_page() - + seq = [] for node in master_node.getElementsByTagName("tag"): tag = Tag(_extract(node, "name"), self.network) tag.tag_count = _number(_extract(node, "count")) seq.append(tag) - + return seq class TrackSearch(_Search): """Search for a track by track title. If you don't wanna narrow the results down by specifying the artist name, set it to empty string.""" - + def __init__(self, artist_name, track_title, network): - + _Search.__init__(self, "track", {"track": track_title, "artist": artist_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - + master_node = self._retrieve_next_page() - + seq = [] for node in master_node.getElementsByTagName("track"): track = Track(_extract(node, "artist"), _extract(node, "name"), self.network) track.listener_count = _number(_extract(node, "listeners")) seq.append(track) - + return seq class VenueSearch(_Search): """Search for a venue by its name. If you don't wanna narrow the results down by specifying a country, set it to empty string.""" - + def __init__(self, venue_name, country_name, network): - + _Search.__init__(self, "venue", {"venue": venue_name, "country": country_name}, network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - + master_node = self._retrieve_next_page() - + seq = [] for node in master_node.getElementsByTagName("venue"): seq.append(Venue(_extract(node, "id"), self.network)) - + return seq class Venue(_BaseObject): """A venue where events are held.""" - + # TODO: waiting for a venue.getInfo web service to use. - + id = None - + def __init__(self, id, network): _BaseObject.__init__(self, network) - + self.id = _number(id) - + def __repr__(self): return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) - + @_string_output def __str__(self): return "Venue #" + str(self.id) - + def __eq__(self, other): return self.get_id() == other.get_id() - + def _get_params(self): return {"venue": self.get_id()} - + def get_id(self): """Returns the id of the venue.""" - + return self.id - + def get_upcoming_events(self): """Returns the upcoming events in this venue.""" - + doc = self._request("venue.getEvents", True) - + seq = [] for node in doc.getElementsByTagName("event"): seq.append(Event(_extract(node, "id"), self.network)) - + return seq - + def get_past_events(self): """Returns the past events held in this venue.""" - + doc = self._request("venue.getEvents", True) - + seq = [] for node in doc.getElementsByTagName("event"): seq.append(Event(_extract(node, "id"), self.network)) - + return seq def md5(text): """Returns the md5 hash of a string.""" - + h = hashlib.md5() h.update(_unicode(text).encode("utf-8")) - + return h.hexdigest() def _unicode(text): @@ -3505,7 +3505,7 @@ def _unicode(text): return text else: return str(text) - + elif sys.version_info[0] ==2: if type(text) in (str,): return unicode(text, "utf-8") @@ -3516,20 +3516,20 @@ def _unicode(text): def _string(text): """For Python2 routines that can only process str type.""" - + if sys.version_info[0] == 3: if type(text) != str: return str(text) else: return text - + elif sys.version_info[0] == 2: if type(text) == str: return text - + if type(text) == int: return str(text) - + return text.encode("utf-8") def _collect_nodes(limit, sender, method_name, cacheable, params=None): @@ -3537,43 +3537,43 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): Returns a sequqnce of dom.Node objects about as close to limit as possible """ - + if not params: params = sender._get_params() - + nodes = [] page = 1 end_of_pages = False - + while not end_of_pages and (not limit or (limit and len(nodes) < limit)): params["page"] = str(page) doc = sender._request(method_name, cacheable, params) - + main = doc.documentElement.childNodes[1] - + if main.hasAttribute("totalPages"): total_pages = _number(main.getAttribute("totalPages")) elif main.hasAttribute("totalpages"): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") - + for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: nodes.append(node) - + if page >= total_pages: end_of_pages = True - + page += 1 - + return nodes - + def _extract(node, name, index = 0): """Extracts a value from the xml string""" - + nodes = node.getElementsByTagName(name) - + if len(nodes): if nodes[index].firstChild: return _unescape_htmlentity(nodes[index].firstChild.data.strip()) @@ -3582,27 +3582,27 @@ def _extract(node, name, index = 0): def _extract_all(node, name, limit_count = None): """Extracts all the values from the xml string. returning a list.""" - + seq = [] - + for i in range(0, len(node.getElementsByTagName(name))): if len(seq) == limit_count: break - + seq.append(_extract(node, name, i)) - + return seq def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a url.""" - + return url_quote_plus(url_quote_plus(_string(text))).lower() def _number(string): """ Extracts an int from a string. Returns a 0 if None or an empty string was passed """ - + if not string: return 0 elif string == "": @@ -3614,29 +3614,29 @@ def _number(string): return float(string) def _unescape_htmlentity(string): - + #string = _unicode(string) - + mapping = htmlentitydefs.name2codepoint for key in mapping: string = string.replace("&%s;" %key, unichr(mapping[key])) - + return string - + def extract_items(topitems_or_libraryitems): """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects.""" - + seq = [] for i in topitems_or_libraryitems: seq.append(i.item) - + return seq class ScrobblingError(Exception): def __init__(self, message): Exception.__init__(self) self.message = message - + @_string_output def __str__(self): return self.message @@ -3658,20 +3658,20 @@ class BadSessionError(ScrobblingError): ScrobblingError.__init__(self, "Bad session id, consider re-handshaking") class _ScrobblerRequest(object): - + def __init__(self, url, params, network, type="POST"): - + for key in params: params[key] = str(params[key]) - + self.params = params self.type = type (self.hostname, self.subdir) = url_split_host(url[len("http:"):]) self.network = network - + def execute(self): """Returns a string response of this request.""" - + connection = HTTPConnection(self.hostname) data = [] @@ -3679,31 +3679,31 @@ class _ScrobblerRequest(object): value = url_quote_plus(self.params[name]) data.append('='.join((name, value))) data = "&".join(data) - + headers = { "Content-type": "application/x-www-form-urlencoded", "Accept-Charset": "utf-8", "User-Agent": "pylast" + "/" + __version__, "HOST": self.hostname } - + if self.type == "GET": connection.request("GET", self.subdir + "?" + data, headers = headers) else: connection.request("POST", self.subdir, data, headers) response = _unicode(connection.getresponse().read()) - + self._check_response_for_errors(response) - + return response - + def _check_response_for_errors(self, response): """When passed a string response it checks for erros, raising any exceptions as necessary.""" - + lines = response.split("\n") status_line = lines[0] - + if status_line == "OK": return elif status_line == "BANNED": @@ -3717,70 +3717,70 @@ class _ScrobblerRequest(object): elif status_line.startswith("FAILED "): reason = status_line[status_line.find("FAILED ")+len("FAILED "):] raise ScrobblingError(reason) - + class Scrobbler(object): """A class for scrobbling tracks to Last.fm""" - + session_id = None nowplaying_url = None submissions_url = None - + def __init__(self, network, client_id, client_version): self.client_id = client_id self.client_version = client_version self.username = network.username self.password = network.password_hash self.network = network - + def _do_handshake(self): """Handshakes with the server""" - + timestamp = str(int(time.time())) - + if self.password and self.username: token = md5(self.password + timestamp) elif self.network.api_key and self.network.api_secret and self.network.session_key: if not self.username: self.username = self.network.get_authenticated_user().get_name() token = md5(self.network.api_secret + timestamp) - + params = {"hs": "true", "p": "1.2.1", "c": self.client_id, "v": self.client_version, "u": self.username, "t": timestamp, "a": token} - + if self.network.session_key and self.network.api_key: params["sk"] = self.network.session_key params["api_key"] = self.network.api_key - + server = self.network.submission_server response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n") - + self.session_id = response[1] self.nowplaying_url = response[2] self.submissions_url = response[3] - + def _get_session_id(self, new = False): """Returns a handshake. If new is true, then it will be requested from the server even if one was cached.""" - + if not self.session_id or new: self._do_handshake() - + return self.session_id - + def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): - + _deprecation_warning("DeprecationWarning: Use Netowrk.update_now_playing(...) instead") - + params = {"s": self._get_session_id(), "a": artist, "t": title, "b": album, "l": duration, "n": track_number, "m": mbid} - + try: _ScrobblerRequest(self.nowplaying_url, params, self.network).execute() except BadSessionError: self._do_handshake() self.report_now_playing(artist, title, album, duration, track_number, mbid) - + def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""): """Scrobble a track. parameters: artist: Artist name. @@ -3802,33 +3802,33 @@ class Scrobbler(object): track_number: The track number on the album. mbid: MusicBrainz ID. """ - + _deprecation_warning("DeprecationWarning: Use Network.scrobble(...) instead") - + params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title), "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration), "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid} - + _ScrobblerRequest(self.submissions_url, params, self.network).execute() - + def scrobble_many(self, tracks): """ Scrobble several tracks at once. - + tracks: A sequence of a sequence of parameters for each trach. The order of parameters is the same as if passed to the scrobble() method. """ - + _deprecation_warning("DeprecationWarning: Use Network.scrobble_many(...) instead") - + remainder = [] - + if len(tracks) > 50: remainder = tracks[50:] tracks = tracks[:50] - + params = {"s": self._get_session_id()} - + i = 0 for t in tracks: _pad_list(t, 9, "") @@ -3841,10 +3841,10 @@ class Scrobbler(object): params["b[%s]" % str(i)] = _string(t[6]) params["n[%s]" % str(i)] = t[7] params["m[%s]" % str(i)] = t[8] - + i += 1 - + _ScrobblerRequest(self.submissions_url, params, self.network).execute() - + if remainder: self.scrobble_many(remainder) From 83ee68a40002e3f405e9818316e49bf0f5c1275d Mon Sep 17 00:00:00 2001 From: "Elizabeth J. Myers" Date: Wed, 16 Jan 2013 11:35:44 -0600 Subject: [PATCH 008/768] Update URL not author 'cause I think that's rude until I make more changes. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 4964af9..50654e0 100755 --- a/setup.py +++ b/setup.py @@ -22,11 +22,11 @@ def get_build(): return str(build) setup(name = "pylast", - version = "0.5." + get_build(), + version = "0.1+0.5." + get_build(), author = "Amr Hassan ", description = "A Python interface to Last.fm (and other API compatible social networks)", author_email = "amr.hassan@gmail.com", - url = "http://code.google.com/p/pylast/", + url = "https://github.com/Elizacat/", py_modules = ("pylast",), license = "Apache2" ) From 78aac5c437eefb037d7c92ac3300be5083417270 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 24 Jan 2013 19:37:02 +0200 Subject: [PATCH 009/768] Added user.getArtistTracks --- pylast.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/pylast.py b/pylast.py index 799065d..e2d6a64 100644 --- a/pylast.py +++ b/pylast.py @@ -2832,12 +2832,30 @@ class User(_BaseObject): for e_id in ids: events.append(Event(e_id, self.network)) - + return events + + def get_artist_tracks(self, artist): + """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" + # Not implemented: "Can be limited to specific timeranges, defaults to all time." + + params = self._get_params() + params['artist'] = artist + + seq = [] + for track in _collect_nodes(None, self, "user.getArtistTracks", False, params): + title = _extract(track, "name") + artist = _extract(track, "artist") + date = _extract(track, "date") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + + return seq def get_friends(self, limit = 50): """Returns a list of the user's friends. """ - + seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) @@ -3534,7 +3552,7 @@ def _string(text): def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ - Returns a sequqnce of dom.Node objects about as close to + Returns a sequence of dom.Node objects about as close to limit as possible """ @@ -3557,14 +3575,14 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") - + for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: + if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): nodes.append(node) - + if page >= total_pages: end_of_pages = True - + page += 1 return nodes From e2cc69a7e873dcc4917e158401bd904187f4727c Mon Sep 17 00:00:00 2001 From: Matt Jeffery Date: Fri, 1 Mar 2013 19:38:41 +0000 Subject: [PATCH 010/768] works with CDATA tags --- .build | 2 +- AUTHORS | 2 + MANIFEST.in | 6 -- PKG-INFO | 10 +++ README | 4 +- pylast.py | 213 +++++++++++++++++++++++----------------------------- setup.py | 9 ++- 7 files changed, 116 insertions(+), 130 deletions(-) create mode 100644 AUTHORS delete mode 100755 MANIFEST.in create mode 100644 PKG-INFO mode change 100755 => 100644 setup.py diff --git a/.build b/.build index da2d398..9d60796 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -14 \ No newline at end of file +11 \ No newline at end of file diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..053c3a0 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,2 @@ +Amr Hassan +Lukas Lipka diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100755 index 9e84b8c..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include pylast.py -include setup.py -include README -include COPYING -include INSTALL -include .build diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..e6dad55 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: pylast +Version: 0.5.11 +Summary: A Python interface to Last.fm (and other API compatible social networks) +Home-page: http://code.google.com/p/pylast/ +Author: Amr Hassan +Author-email: amr.hassan@gmail.com +License: Apache2 +Description: UNKNOWN +Platform: UNKNOWN diff --git a/README b/README index 923259e..64b1b7f 100644 --- a/README +++ b/README @@ -3,5 +3,5 @@ pylast A python interface to Last.fm. Try using the pydoc utility for help on usage. - -Original code can be found at: http://code.google.com/p/pylast/ +For more info check out the project's home page at http://code.google.com/p/pylast/ +or the mailing list http://groups.google.com/group/pylast/ diff --git a/pylast.py b/pylast.py index e2d6a64..c73f9e0 100644 --- a/pylast.py +++ b/pylast.py @@ -47,7 +47,7 @@ if sys.version_info[0] == 3: elif sys.version_info[0] == 2: from httplib import HTTPConnection - import htmlentitydefs + import htmlentitydefs from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -143,7 +143,7 @@ class _Network(object): """ self.name = name - self.homepage = homepage + self.homepage = homepage self.ws_server = ws_server self.api_key = api_key self.api_secret = api_secret @@ -241,13 +241,13 @@ class _Network(object): Quote from http://www.last.fm/api/submissions: ======== - Client identifiers are used to provide a centrally managed database of - the client versions, allowing clients to be banned if they are found to - be behaving undesirably. The client ID is associated with a version - number on the server, however these are only incremented if a client is + Client identifiers are used to provide a centrally managed database of + the client versions, allowing clients to be banned if they are found to + be behaving undesirably. The client ID is associated with a version + number on the server, however these are only incremented if a client is banned and do not have to reflect the version of the actual client application. - During development, clients which have not been allocated an identifier should + During development, clients which have not been allocated an identifier should use the identifier tst, with a version number of 1.0. Do not distribute code or client implementations which use this test identifier. Do not use the identifiers used by other clients. @@ -257,7 +257,7 @@ class _Network(object): * Last.fm: submissions@last.fm * # TODO: list others - ...and provide us with the name of your client and its homepage address. + ...and provide us with the name of your client and its homepage address. """ _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") @@ -313,6 +313,37 @@ class _Network(object): return Playlist(user, e_id, self) + def get_top_artists(self, limit=None): + """Returns a sequence of the most played artists.""" + + doc = _Request(self, "chart.getTopArtists").execute(True) + seq = [] + for node in doc.getElementsByTagName("artist"): + title = _extract(node, "name") + artist = Artist(title, self) + seq.append(artist) + + if limit: + seq = seq[:limit] + + return seq + + def get_top_tracks(self, limit=None): + """Returns a sequence of the most played tracks.""" + + doc = _Request(self, "chart.getTopTracks").execute(True) + seq = [] + for node in doc.getElementsByTagName("track"): + title = _extract(node, "name") + artist = _extract(node, "name", 1) + track = Track(artist, title, self) + seq.append(track) + + if limit: + seq = seq[:limit] + + return seq + def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" @@ -352,8 +383,10 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. + In choosing the backend used for caching, it will try _SqliteCacheBackend first if + the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. - * file_path: A file path for the backend storage file. If + * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ @@ -435,10 +468,10 @@ class _Network(object): return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - def update_now_playing(self, artist, title, album = None, album_artist = None, + def update_now_playing(self, artist, title, album = None, album_artist = None, duration = None, track_number = None, mbid = None, context = None): """ - Used to notify Last.fm that a user has started listening to a track. + Used to notify Last.fm that a user has started listening to a track. Parameters: artist (Required) : The artist name @@ -462,7 +495,7 @@ class _Network(object): _Request(self, "track.updateNowPlaying", params).execute() - def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, + def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, duration = None, stream_id = None, context = None, mbid = None): """Used to add a track-play to a user's profile. @@ -578,7 +611,7 @@ class LastFMNetwork(_Network): ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -661,7 +694,7 @@ class LibreFMNetwork(_Network): ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -788,7 +821,7 @@ class _Request(object): "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ - } + } (HOST_NAME, HOST_SUBDIR) = self.network.ws_server @@ -796,7 +829,7 @@ class _Request(object): conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) try: - conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -864,7 +897,7 @@ class SessionKeyGenerator(object): manually, unless you want to. """ - def __init__(self, network): + def __init__(self, network): self.network = network self.web_auth_tokens = {} @@ -1150,9 +1183,8 @@ class Album(_BaseObject, _Taggable): title = None artist = None - username = None - def __init__(self, artist, title, network, username=None): + def __init__(self, artist, title, network): """ Create an album instance. # Parameters: @@ -1169,7 +1201,6 @@ class Album(_BaseObject, _Taggable): self.artist = Artist(artist, self.network) self.title = title - self.username = username def __repr__(self): return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -1229,16 +1260,6 @@ class Album(_BaseObject, _Taggable): return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) - def get_userplaycount(self): - """Returns the number of plays by a given username""" - - if not self.username: return - - params = self._get_params() - params['username'] = self.username - - return _number(_extract(self._request("album.getInfo", True, params), "userplaycount")) - def get_listener_count(self): """Returns the number of liteners on the network""" @@ -1272,7 +1293,7 @@ class Album(_BaseObject, _Taggable): return _extract(self._request("album.getInfo", cacheable = True), "mbid") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the album page on the network. + """Returns the url of the album page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1334,9 +1355,8 @@ class Artist(_BaseObject, _Taggable): """An artist.""" name = None - username = None - def __init__(self, name, network, username=None): + def __init__(self, name, network): """Create an artist object. # Parameters: * name str: The artist's name. @@ -1346,7 +1366,6 @@ class Artist(_BaseObject, _Taggable): _Taggable.__init__(self, 'artist') self.name = name - self.username = username def __repr__(self): return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) @@ -1392,16 +1411,6 @@ class Artist(_BaseObject, _Taggable): return _number(_extract(self._request("artist.getInfo", True), "playcount")) - def get_userplaycount(self): - """Returns the number of plays by a given username""" - - if not self.username: return - - params = self._get_params() - params['username'] = self.username - - return _number(_extract(self._request("artist.getInfo", True, params), "userplaycount")) - def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" @@ -1537,10 +1546,10 @@ class Artist(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this artist (sends out recommendations). + """Shares this artist (sends out recommendations). # Parameters: * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. - * message str: A message to include in the recommendation message. + * message str: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1565,7 +1574,7 @@ class Artist(_BaseObject, _Taggable): self._request('artist.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the artist page on the network. + """Returns the url of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1579,7 +1588,7 @@ class Artist(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_name()) @@ -1675,7 +1684,7 @@ class Event(_BaseObject): * attending_status: The attending status. Possible values: o EVENT_ATTENDING o EVENT_MAYBE_ATTENDING - o EVENT_NOT_ATTENDING + o EVENT_NOT_ATTENDING """ params = self._get_params() @@ -1781,7 +1790,7 @@ class Event(_BaseObject): return _number(_extract(doc, "reviews")) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1794,15 +1803,15 @@ class Event(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ return self.network._get_url(domain_name, "event") %{'id': self.get_id()} def share(self, users, message = None): - """Shares this event (sends out recommendations). + """Shares this event (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1919,7 +1928,7 @@ class Country(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1932,7 +1941,7 @@ class Country(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ country_name = _url_safe(self.get_name()) @@ -2173,7 +2182,7 @@ class Playlist(_BaseObject): return _extract(self._get_info_node(), "image")[size] def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the playlist on the network. + """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2186,7 +2195,7 @@ class Playlist(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ english_url = _extract(self._get_info_node(), "url") @@ -2319,7 +2328,7 @@ class Tag(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the tag page on the network. + """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2332,7 +2341,7 @@ class Tag(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2344,9 +2353,8 @@ class Track(_BaseObject, _Taggable): artist = None title = None - username = None - def __init__(self, artist, title, network, username=None): + def __init__(self, artist, title, network): _BaseObject.__init__(self, network) _Taggable.__init__(self, 'track') @@ -2357,8 +2365,6 @@ class Track(_BaseObject, _Taggable): self.title = title - self.username = username - def __repr__(self): return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -2430,17 +2436,6 @@ class Track(_BaseObject, _Taggable): doc = self._request("track.getInfo", True) return _number(_extract(doc, "playcount")) - def get_userplaycount(self): - """Returns the number of plays by a given username""" - - if not self.username: return - - params = self._get_params() - params['username'] = self.username - - doc = self._request("track.getInfo", True, params) - return _number(_extract(doc, "userplaycount")) - def is_streamable(self): """Returns True if the track is available at Last.fm.""" @@ -2548,9 +2543,9 @@ class Track(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this track (sends out recommendations). + """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -2575,7 +2570,7 @@ class Track(_BaseObject, _Taggable): self._request('track.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the track page on the network. + """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2588,7 +2583,7 @@ class Track(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) @@ -2707,7 +2702,7 @@ class Group(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the group page on the network. + """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2720,7 +2715,7 @@ class Group(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2832,30 +2827,12 @@ class User(_BaseObject): for e_id in ids: events.append(Event(e_id, self.network)) - + return events - - def get_artist_tracks(self, artist): - """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" - # Not implemented: "Can be limited to specific timeranges, defaults to all time." - - params = self._get_params() - params['artist'] = artist - - seq = [] - for track in _collect_nodes(None, self, "user.getArtistTracks", False, params): - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - - seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) - - return seq def get_friends(self, limit = 50): """Returns a list of the user's friends. """ - + seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) @@ -2946,7 +2923,7 @@ class User(_BaseObject): artist = _extract(e, 'artist') title = _extract(e, 'name') - return Track(artist, title, self.network, self.name) + return Track(artist, title, self.network) def get_recent_tracks(self, limit = 10): @@ -3037,13 +3014,13 @@ class User(_BaseObject): return _number(_extract(doc, "playcount")) def get_top_albums(self, period = PERIOD_OVERALL): - """Returns the top albums played by a user. + """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3062,13 +3039,13 @@ class User(_BaseObject): return seq def get_top_artists(self, period = PERIOD_OVERALL): - """Returns the top artists played by a user. + """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3086,8 +3063,8 @@ class User(_BaseObject): return seq def get_top_tags(self, limit=None): - """Returns a sequence of the top tags used by this user with their counts as TopItem objects. - * limit: The limit of how many tags to return. + """Returns a sequence of the top tags used by this user with their counts as TopItem objects. + * limit: The limit of how many tags to return. """ doc = self._request("user.getTopTags", True) @@ -3102,13 +3079,13 @@ class User(_BaseObject): return seq def get_top_tracks(self, period = PERIOD_OVERALL): - """Returns the top tracks played by a user. + """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3230,7 +3207,7 @@ class User(_BaseObject): return _extract(doc, "image") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the user page on the network. + """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -3243,7 +3220,7 @@ class User(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -3552,7 +3529,7 @@ def _string(text): def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ - Returns a sequence of dom.Node objects about as close to + Returns a sequqnce of dom.Node objects about as close to limit as possible """ @@ -3575,14 +3552,14 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") - + for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): + if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: nodes.append(node) - + if page >= total_pages: end_of_pages = True - + page += 1 return nodes @@ -3594,7 +3571,7 @@ def _extract(node, name, index = 0): if len(nodes): if nodes[index].firstChild: - return _unescape_htmlentity(nodes[index].firstChild.data.strip()) + return _unescape_htmlentity(nodes[index].firstChild.wholeText.strip()) else: return None @@ -3633,7 +3610,7 @@ def _number(string): def _unescape_htmlentity(string): - #string = _unicode(string) + #string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 50654e0..9215a42 --- a/setup.py +++ b/setup.py @@ -1,6 +1,9 @@ #!/usr/bin/env python -from distutils.core import setup +try: + from setuptools import setup +except: + from distutils.core import setup import os def get_build(): @@ -22,11 +25,11 @@ def get_build(): return str(build) setup(name = "pylast", - version = "0.1+0.5." + get_build(), + version = "0.5." + get_build(), author = "Amr Hassan ", description = "A Python interface to Last.fm (and other API compatible social networks)", author_email = "amr.hassan@gmail.com", - url = "https://github.com/Elizacat/", + url = "http://code.google.com/p/pylast/", py_modules = ("pylast",), license = "Apache2" ) From bb33663472497a70314af0dafe98527afb4fd01a Mon Sep 17 00:00:00 2001 From: Matt Jeffery Date: Fri, 1 Mar 2013 20:14:18 +0000 Subject: [PATCH 011/768] replace '&' with & when it is not in a character entity (approximation) --- pylast.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pylast.py b/pylast.py index c73f9e0..b3f9572 100644 --- a/pylast.py +++ b/pylast.py @@ -33,6 +33,7 @@ import tempfile import sys import collections import warnings +import re def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -847,6 +848,10 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) + # Pretty decent catch for invalid & characters - which last.fm + # seems to generate for some artist eg. "K'nann" + response_text = re.sub("&(?![^\W]+;)", "&", response_text) + self._check_response_for_errors(response_text) return response_text From 4621a1a6e4a27879b78d2c3d3cf4a69881b8a1a3 Mon Sep 17 00:00:00 2001 From: Matt Jeffery Date: Wed, 13 Mar 2013 13:37:21 +0000 Subject: [PATCH 012/768] boilerplate gitignore file --- .gitignore | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9116a7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + From 88f5b5e6b9b9a0e0ddc6e073e0f8e39619b8f57b Mon Sep 17 00:00:00 2001 From: Andrew Moss Date: Thu, 26 Dec 2013 23:11:23 +0000 Subject: [PATCH 013/768] Allow getting venue info through Event.get_venue() as a workaround until the Venue.getInfo API call is added to Last.fm --- pylast.py | 176 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 116 insertions(+), 60 deletions(-) diff --git a/pylast.py b/pylast.py index e2d6a64..fa961ad 100644 --- a/pylast.py +++ b/pylast.py @@ -47,7 +47,7 @@ if sys.version_info[0] == 3: elif sys.version_info[0] == 2: from httplib import HTTPConnection - import htmlentitydefs + import htmlentitydefs from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -143,7 +143,7 @@ class _Network(object): """ self.name = name - self.homepage = homepage + self.homepage = homepage self.ws_server = ws_server self.api_key = api_key self.api_secret = api_secret @@ -241,13 +241,13 @@ class _Network(object): Quote from http://www.last.fm/api/submissions: ======== - Client identifiers are used to provide a centrally managed database of - the client versions, allowing clients to be banned if they are found to - be behaving undesirably. The client ID is associated with a version - number on the server, however these are only incremented if a client is + Client identifiers are used to provide a centrally managed database of + the client versions, allowing clients to be banned if they are found to + be behaving undesirably. The client ID is associated with a version + number on the server, however these are only incremented if a client is banned and do not have to reflect the version of the actual client application. - During development, clients which have not been allocated an identifier should + During development, clients which have not been allocated an identifier should use the identifier tst, with a version number of 1.0. Do not distribute code or client implementations which use this test identifier. Do not use the identifiers used by other clients. @@ -257,7 +257,7 @@ class _Network(object): * Last.fm: submissions@last.fm * # TODO: list others - ...and provide us with the name of your client and its homepage address. + ...and provide us with the name of your client and its homepage address. """ _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") @@ -353,7 +353,7 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - * file_path: A file path for the backend storage file. If + * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ @@ -435,10 +435,10 @@ class _Network(object): return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - def update_now_playing(self, artist, title, album = None, album_artist = None, + def update_now_playing(self, artist, title, album = None, album_artist = None, duration = None, track_number = None, mbid = None, context = None): """ - Used to notify Last.fm that a user has started listening to a track. + Used to notify Last.fm that a user has started listening to a track. Parameters: artist (Required) : The artist name @@ -462,7 +462,7 @@ class _Network(object): _Request(self, "track.updateNowPlaying", params).execute() - def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, + def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, duration = None, stream_id = None, context = None, mbid = None): """Used to add a track-play to a user's profile. @@ -578,7 +578,7 @@ class LastFMNetwork(_Network): ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -661,7 +661,7 @@ class LibreFMNetwork(_Network): ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -788,7 +788,7 @@ class _Request(object): "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ - } + } (HOST_NAME, HOST_SUBDIR) = self.network.ws_server @@ -796,7 +796,7 @@ class _Request(object): conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) try: - conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -864,7 +864,7 @@ class SessionKeyGenerator(object): manually, unless you want to. """ - def __init__(self, network): + def __init__(self, network): self.network = network self.web_auth_tokens = {} @@ -1272,7 +1272,7 @@ class Album(_BaseObject, _Taggable): return _extract(self._request("album.getInfo", cacheable = True), "mbid") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the album page on the network. + """Returns the url of the album page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1537,10 +1537,10 @@ class Artist(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this artist (sends out recommendations). + """Shares this artist (sends out recommendations). # Parameters: * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. - * message str: A message to include in the recommendation message. + * message str: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1565,7 +1565,7 @@ class Artist(_BaseObject, _Taggable): self._request('artist.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the artist page on the network. + """Returns the url of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1579,7 +1579,7 @@ class Artist(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_name()) @@ -1675,7 +1675,7 @@ class Event(_BaseObject): * attending_status: The attending status. Possible values: o EVENT_ATTENDING o EVENT_MAYBE_ATTENDING - o EVENT_NOT_ATTENDING + o EVENT_NOT_ATTENDING """ params = self._get_params() @@ -1735,7 +1735,7 @@ class Event(_BaseObject): v = doc.getElementsByTagName("venue")[0] venue_id = _number(_extract(v, "id")) - return Venue(venue_id, self.network) + return Venue(venue_id, self.network, venue_element=v) def get_start_date(self): """Returns the date when the event starts.""" @@ -1781,7 +1781,7 @@ class Event(_BaseObject): return _number(_extract(doc, "reviews")) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1794,15 +1794,15 @@ class Event(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ return self.network._get_url(domain_name, "event") %{'id': self.get_id()} def share(self, users, message = None): - """Shares this event (sends out recommendations). + """Shares this event (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1919,7 +1919,7 @@ class Country(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1932,7 +1932,7 @@ class Country(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ country_name = _url_safe(self.get_name()) @@ -2173,7 +2173,7 @@ class Playlist(_BaseObject): return _extract(self._get_info_node(), "image")[size] def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the playlist on the network. + """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2186,7 +2186,7 @@ class Playlist(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ english_url = _extract(self._get_info_node(), "url") @@ -2319,7 +2319,7 @@ class Tag(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the tag page on the network. + """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2332,7 +2332,7 @@ class Tag(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2548,9 +2548,9 @@ class Track(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this track (sends out recommendations). + """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -2575,7 +2575,7 @@ class Track(_BaseObject, _Taggable): self._request('track.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the track page on the network. + """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2588,7 +2588,7 @@ class Track(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) @@ -2707,7 +2707,7 @@ class Group(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the group page on the network. + """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2720,7 +2720,7 @@ class Group(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2832,13 +2832,13 @@ class User(_BaseObject): for e_id in ids: events.append(Event(e_id, self.network)) - + return events - + def get_artist_tracks(self, artist): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" # Not implemented: "Can be limited to specific timeranges, defaults to all time." - + params = self._get_params() params['artist'] = artist @@ -2848,14 +2848,14 @@ class User(_BaseObject): artist = _extract(track, "artist") date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) return seq def get_friends(self, limit = 50): """Returns a list of the user's friends. """ - + seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) @@ -3037,13 +3037,13 @@ class User(_BaseObject): return _number(_extract(doc, "playcount")) def get_top_albums(self, period = PERIOD_OVERALL): - """Returns the top albums played by a user. + """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3062,13 +3062,13 @@ class User(_BaseObject): return seq def get_top_artists(self, period = PERIOD_OVERALL): - """Returns the top artists played by a user. + """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3086,8 +3086,8 @@ class User(_BaseObject): return seq def get_top_tags(self, limit=None): - """Returns a sequence of the top tags used by this user with their counts as TopItem objects. - * limit: The limit of how many tags to return. + """Returns a sequence of the top tags used by this user with their counts as TopItem objects. + * limit: The limit of how many tags to return. """ doc = self._request("user.getTopTags", True) @@ -3102,13 +3102,13 @@ class User(_BaseObject): return seq def get_top_tracks(self, period = PERIOD_OVERALL): - """Returns the top tracks played by a user. + """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3230,7 +3230,7 @@ class User(_BaseObject): return _extract(doc, "image") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the user page on the network. + """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -3243,7 +3243,7 @@ class User(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -3459,13 +3459,25 @@ class Venue(_BaseObject): """A venue where events are held.""" # TODO: waiting for a venue.getInfo web service to use. + # TODO: As an intermediate use case, can pass the venue DOM element when using + # Event.get_venue() to populate the venue info, if the venue.getInfo API + # call becomes available this workaround should be removed id = None + info = None + name = None + location = None + url = None - def __init__(self, id, network): + def __init__(self, id, network, venue_element=None): _BaseObject.__init__(self, network) self.id = _number(id) + if venue_element is not None: + self.info = _extract_element_tree(venue_element) + self.name = self.info.get('name') + self.url = self.info.get('url') + self.location = self.info.get('location') def __repr__(self): return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) @@ -3485,6 +3497,21 @@ class Venue(_BaseObject): return self.id + def get_name(self): + """Returns the name of the venue.""" + + return self.name + + def get_url(self): + """Returns the URL of the venue page.""" + + return self.url + + def get_location(self): + """Returns the location of the venue (dictionary).""" + + return self.location + def get_upcoming_events(self): """Returns the upcoming events in this venue.""" @@ -3575,14 +3602,14 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") - + for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): nodes.append(node) - + if page >= total_pages: end_of_pages = True - + page += 1 return nodes @@ -3598,6 +3625,35 @@ def _extract(node, name, index = 0): else: return None +def _extract_element_tree(node, index = 0): + """Extract an element tree into a multi-level dictionary + + NB: If any elements have text nodes as well as nested + elements this will ignore the text nodes""" + + def _recurse_build_tree(rootNode, targetDict): + """Recursively build a multi-level dict""" + + def _has_child_elements(rootNode): + """Check if an element has any nested (child) elements""" + + for node in rootNode.childNodes: + if node.nodeType == node.ELEMENT_NODE: + return True + return False + + for node in rootNode.childNodes: + if node.nodeType == node.ELEMENT_NODE: + if _has_child_elements(node): + targetDict[node.tagName] = {} + _recurse_build_tree(node, targetDict[node.tagName]) + else: + targetDict[node.tagName] = _unescape_htmlentity(node.firstChild.data.strip()) + + return targetDict + + return _recurse_build_tree(node, {}) + def _extract_all(node, name, limit_count = None): """Extracts all the values from the xml string. returning a list.""" @@ -3633,7 +3689,7 @@ def _number(string): def _unescape_htmlentity(string): - #string = _unicode(string) + #string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: From c8216a139e216f016773de3eb92a1044ea919c1e Mon Sep 17 00:00:00 2001 From: Andrew Moss Date: Thu, 26 Dec 2013 23:11:23 +0000 Subject: [PATCH 014/768] Allow getting venue info through Event.get_venue() as a workaround until the Venue.getInfo API call is added to Last.fm. Changed version to 0.6 in setup.py and pylast.py to reflect update. --- .build | 2 +- .gitignore | 37 ------------ AUTHORS | 2 - MANIFEST.in | 6 ++ PKG-INFO | 10 ---- README | 4 +- pylast.py | 168 +++++++++++++++++++++++++++++++++++++--------------- setup.py | 9 +-- 8 files changed, 133 insertions(+), 105 deletions(-) delete mode 100644 .gitignore delete mode 100644 AUTHORS create mode 100755 MANIFEST.in delete mode 100644 PKG-INFO mode change 100644 => 100755 setup.py diff --git a/.build b/.build index 9d60796..da2d398 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -11 \ No newline at end of file +14 \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9116a7b..0000000 --- a/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -*.py[cod] - -# C extensions -*.so - -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs -.installed.cfg -lib -lib64 -__pycache__ - -# Installer logs -pip-log.txt - -# Unit test / coverage reports -.coverage -.tox -nosetests.xml - -# Translations -*.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 053c3a0..0000000 --- a/AUTHORS +++ /dev/null @@ -1,2 +0,0 @@ -Amr Hassan -Lukas Lipka diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100755 index 0000000..9e84b8c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include pylast.py +include setup.py +include README +include COPYING +include INSTALL +include .build diff --git a/PKG-INFO b/PKG-INFO deleted file mode 100644 index e6dad55..0000000 --- a/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 1.0 -Name: pylast -Version: 0.5.11 -Summary: A Python interface to Last.fm (and other API compatible social networks) -Home-page: http://code.google.com/p/pylast/ -Author: Amr Hassan -Author-email: amr.hassan@gmail.com -License: Apache2 -Description: UNKNOWN -Platform: UNKNOWN diff --git a/README b/README index 64b1b7f..923259e 100644 --- a/README +++ b/README @@ -3,5 +3,5 @@ pylast A python interface to Last.fm. Try using the pydoc utility for help on usage. -For more info check out the project's home page at http://code.google.com/p/pylast/ -or the mailing list http://groups.google.com/group/pylast/ + +Original code can be found at: http://code.google.com/p/pylast/ diff --git a/pylast.py b/pylast.py index b3f9572..fa961ad 100644 --- a/pylast.py +++ b/pylast.py @@ -33,7 +33,6 @@ import tempfile import sys import collections import warnings -import re def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -314,37 +313,6 @@ class _Network(object): return Playlist(user, e_id, self) - def get_top_artists(self, limit=None): - """Returns a sequence of the most played artists.""" - - doc = _Request(self, "chart.getTopArtists").execute(True) - seq = [] - for node in doc.getElementsByTagName("artist"): - title = _extract(node, "name") - artist = Artist(title, self) - seq.append(artist) - - if limit: - seq = seq[:limit] - - return seq - - def get_top_tracks(self, limit=None): - """Returns a sequence of the most played tracks.""" - - doc = _Request(self, "chart.getTopTracks").execute(True) - seq = [] - for node in doc.getElementsByTagName("track"): - title = _extract(node, "name") - artist = _extract(node, "name", 1) - track = Track(artist, title, self) - seq.append(track) - - if limit: - seq = seq[:limit] - - return seq - def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" @@ -384,8 +352,6 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - In choosing the backend used for caching, it will try _SqliteCacheBackend first if - the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. @@ -848,10 +814,6 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) - # Pretty decent catch for invalid & characters - which last.fm - # seems to generate for some artist eg. "K'nann" - response_text = re.sub("&(?![^\W]+;)", "&", response_text) - self._check_response_for_errors(response_text) return response_text @@ -1188,8 +1150,9 @@ class Album(_BaseObject, _Taggable): title = None artist = None + username = None - def __init__(self, artist, title, network): + def __init__(self, artist, title, network, username=None): """ Create an album instance. # Parameters: @@ -1206,6 +1169,7 @@ class Album(_BaseObject, _Taggable): self.artist = Artist(artist, self.network) self.title = title + self.username = username def __repr__(self): return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -1265,6 +1229,16 @@ class Album(_BaseObject, _Taggable): return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + return _number(_extract(self._request("album.getInfo", True, params), "userplaycount")) + def get_listener_count(self): """Returns the number of liteners on the network""" @@ -1360,8 +1334,9 @@ class Artist(_BaseObject, _Taggable): """An artist.""" name = None + username = None - def __init__(self, name, network): + def __init__(self, name, network, username=None): """Create an artist object. # Parameters: * name str: The artist's name. @@ -1371,6 +1346,7 @@ class Artist(_BaseObject, _Taggable): _Taggable.__init__(self, 'artist') self.name = name + self.username = username def __repr__(self): return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) @@ -1416,6 +1392,16 @@ class Artist(_BaseObject, _Taggable): return _number(_extract(self._request("artist.getInfo", True), "playcount")) + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + return _number(_extract(self._request("artist.getInfo", True, params), "userplaycount")) + def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" @@ -1749,7 +1735,7 @@ class Event(_BaseObject): v = doc.getElementsByTagName("venue")[0] venue_id = _number(_extract(v, "id")) - return Venue(venue_id, self.network) + return Venue(venue_id, self.network, venue_element=v) def get_start_date(self): """Returns the date when the event starts.""" @@ -2358,8 +2344,9 @@ class Track(_BaseObject, _Taggable): artist = None title = None + username = None - def __init__(self, artist, title, network): + def __init__(self, artist, title, network, username=None): _BaseObject.__init__(self, network) _Taggable.__init__(self, 'track') @@ -2370,6 +2357,8 @@ class Track(_BaseObject, _Taggable): self.title = title + self.username = username + def __repr__(self): return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) @@ -2441,6 +2430,17 @@ class Track(_BaseObject, _Taggable): doc = self._request("track.getInfo", True) return _number(_extract(doc, "playcount")) + def get_userplaycount(self): + """Returns the number of plays by a given username""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + doc = self._request("track.getInfo", True, params) + return _number(_extract(doc, "userplaycount")) + def is_streamable(self): """Returns True if the track is available at Last.fm.""" @@ -2835,6 +2835,24 @@ class User(_BaseObject): return events + def get_artist_tracks(self, artist): + """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" + # Not implemented: "Can be limited to specific timeranges, defaults to all time." + + params = self._get_params() + params['artist'] = artist + + seq = [] + for track in _collect_nodes(None, self, "user.getArtistTracks", False, params): + title = _extract(track, "name") + artist = _extract(track, "artist") + date = _extract(track, "date") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + + return seq + def get_friends(self, limit = 50): """Returns a list of the user's friends. """ @@ -2928,7 +2946,7 @@ class User(_BaseObject): artist = _extract(e, 'artist') title = _extract(e, 'name') - return Track(artist, title, self.network) + return Track(artist, title, self.network, self.name) def get_recent_tracks(self, limit = 10): @@ -3441,13 +3459,25 @@ class Venue(_BaseObject): """A venue where events are held.""" # TODO: waiting for a venue.getInfo web service to use. + # TODO: As an intermediate use case, can pass the venue DOM element when using + # Event.get_venue() to populate the venue info, if the venue.getInfo API + # call becomes available this workaround should be removed id = None + info = None + name = None + location = None + url = None - def __init__(self, id, network): + def __init__(self, id, network, venue_element=None): _BaseObject.__init__(self, network) self.id = _number(id) + if venue_element is not None: + self.info = _extract_element_tree(venue_element) + self.name = self.info.get('name') + self.url = self.info.get('url') + self.location = self.info.get('location') def __repr__(self): return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) @@ -3467,6 +3497,21 @@ class Venue(_BaseObject): return self.id + def get_name(self): + """Returns the name of the venue.""" + + return self.name + + def get_url(self): + """Returns the URL of the venue page.""" + + return self.url + + def get_location(self): + """Returns the location of the venue (dictionary).""" + + return self.location + def get_upcoming_events(self): """Returns the upcoming events in this venue.""" @@ -3534,7 +3579,7 @@ def _string(text): def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ - Returns a sequqnce of dom.Node objects about as close to + Returns a sequence of dom.Node objects about as close to limit as possible """ @@ -3559,7 +3604,7 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): raise Exception("No total pages attribute") for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and len(nodes) < limit: + if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): nodes.append(node) if page >= total_pages: @@ -3576,10 +3621,39 @@ def _extract(node, name, index = 0): if len(nodes): if nodes[index].firstChild: - return _unescape_htmlentity(nodes[index].firstChild.wholeText.strip()) + return _unescape_htmlentity(nodes[index].firstChild.data.strip()) else: return None +def _extract_element_tree(node, index = 0): + """Extract an element tree into a multi-level dictionary + + NB: If any elements have text nodes as well as nested + elements this will ignore the text nodes""" + + def _recurse_build_tree(rootNode, targetDict): + """Recursively build a multi-level dict""" + + def _has_child_elements(rootNode): + """Check if an element has any nested (child) elements""" + + for node in rootNode.childNodes: + if node.nodeType == node.ELEMENT_NODE: + return True + return False + + for node in rootNode.childNodes: + if node.nodeType == node.ELEMENT_NODE: + if _has_child_elements(node): + targetDict[node.tagName] = {} + _recurse_build_tree(node, targetDict[node.tagName]) + else: + targetDict[node.tagName] = _unescape_htmlentity(node.firstChild.data.strip()) + + return targetDict + + return _recurse_build_tree(node, {}) + def _extract_all(node, name, limit_count = None): """Extracts all the values from the xml string. returning a list.""" diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 9215a42..50654e0 --- a/setup.py +++ b/setup.py @@ -1,9 +1,6 @@ #!/usr/bin/env python -try: - from setuptools import setup -except: - from distutils.core import setup +from distutils.core import setup import os def get_build(): @@ -25,11 +22,11 @@ def get_build(): return str(build) setup(name = "pylast", - version = "0.5." + get_build(), + version = "0.1+0.5." + get_build(), author = "Amr Hassan ", description = "A Python interface to Last.fm (and other API compatible social networks)", author_email = "amr.hassan@gmail.com", - url = "http://code.google.com/p/pylast/", + url = "https://github.com/Elizacat/", py_modules = ("pylast",), license = "Apache2" ) From eadce48315ba3850890352008a3406bbc0e4a8b2 Mon Sep 17 00:00:00 2001 From: Andrew Moss Date: Fri, 27 Dec 2013 10:08:50 +0000 Subject: [PATCH 015/768] fixed bug wherein empty elements would make get_venue() crash --- pylast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index ce887c1..aab208e 100644 --- a/pylast.py +++ b/pylast.py @@ -3648,8 +3648,8 @@ def _extract_element_tree(node, index = 0): targetDict[node.tagName] = {} _recurse_build_tree(node, targetDict[node.tagName]) else: - targetDict[node.tagName] = _unescape_htmlentity(node.firstChild.data.strip()) - + val = None if node.firstChild is None else _unescape_htmlentity(node.firstChild.data.strip()) + targetDict[node.tagName] = val return targetDict return _recurse_build_tree(node, {}) From b48f56badfe5f1c51578d4a0f680aae0289e232e Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 13:16:28 +0200 Subject: [PATCH 016/768] Bug fix: http://stackoverflow.com/a/5542465/724176 --- pylast.py | 180 +++++++++++++++++++----------------------------------- setup.py | 10 +-- 2 files changed, 67 insertions(+), 123 deletions(-) diff --git a/pylast.py b/pylast.py index aab208e..db824c3 100644 --- a/pylast.py +++ b/pylast.py @@ -18,7 +18,7 @@ # # http://code.google.com/p/pylast/ -__version__ = '0.6' +__version__ = '0.5' __author__ = 'Amr Hassan' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan" __license__ = "apache2" @@ -47,7 +47,7 @@ if sys.version_info[0] == 3: elif sys.version_info[0] == 2: from httplib import HTTPConnection - import htmlentitydefs + import htmlentitydefs from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -143,7 +143,7 @@ class _Network(object): """ self.name = name - self.homepage = homepage + self.homepage = homepage self.ws_server = ws_server self.api_key = api_key self.api_secret = api_secret @@ -241,13 +241,13 @@ class _Network(object): Quote from http://www.last.fm/api/submissions: ======== - Client identifiers are used to provide a centrally managed database of - the client versions, allowing clients to be banned if they are found to - be behaving undesirably. The client ID is associated with a version - number on the server, however these are only incremented if a client is + Client identifiers are used to provide a centrally managed database of + the client versions, allowing clients to be banned if they are found to + be behaving undesirably. The client ID is associated with a version + number on the server, however these are only incremented if a client is banned and do not have to reflect the version of the actual client application. - During development, clients which have not been allocated an identifier should + During development, clients which have not been allocated an identifier should use the identifier tst, with a version number of 1.0. Do not distribute code or client implementations which use this test identifier. Do not use the identifiers used by other clients. @@ -257,7 +257,7 @@ class _Network(object): * Last.fm: submissions@last.fm * # TODO: list others - ...and provide us with the name of your client and its homepage address. + ...and provide us with the name of your client and its homepage address. """ _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") @@ -353,7 +353,7 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. - * file_path: A file path for the backend storage file. If + * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ @@ -435,10 +435,10 @@ class _Network(object): return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - def update_now_playing(self, artist, title, album = None, album_artist = None, + def update_now_playing(self, artist, title, album = None, album_artist = None, duration = None, track_number = None, mbid = None, context = None): """ - Used to notify Last.fm that a user has started listening to a track. + Used to notify Last.fm that a user has started listening to a track. Parameters: artist (Required) : The artist name @@ -462,7 +462,7 @@ class _Network(object): _Request(self, "track.updateNowPlaying", params).execute() - def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, + def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, duration = None, stream_id = None, context = None, mbid = None): """Used to add a track-play to a user's profile. @@ -578,7 +578,7 @@ class LastFMNetwork(_Network): ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -661,7 +661,7 @@ class LibreFMNetwork(_Network): ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, + return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): @@ -788,7 +788,7 @@ class _Request(object): "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ - } + } (HOST_NAME, HOST_SUBDIR) = self.network.ws_server @@ -796,7 +796,7 @@ class _Request(object): conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) try: - conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -864,7 +864,7 @@ class SessionKeyGenerator(object): manually, unless you want to. """ - def __init__(self, network): + def __init__(self, network): self.network = network self.web_auth_tokens = {} @@ -1272,7 +1272,7 @@ class Album(_BaseObject, _Taggable): return _extract(self._request("album.getInfo", cacheable = True), "mbid") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the album page on the network. + """Returns the url of the album page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1537,10 +1537,10 @@ class Artist(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this artist (sends out recommendations). + """Shares this artist (sends out recommendations). # Parameters: * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. - * message str: A message to include in the recommendation message. + * message str: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1565,7 +1565,7 @@ class Artist(_BaseObject, _Taggable): self._request('artist.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the artist page on the network. + """Returns the url of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1579,7 +1579,7 @@ class Artist(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_name()) @@ -1675,7 +1675,7 @@ class Event(_BaseObject): * attending_status: The attending status. Possible values: o EVENT_ATTENDING o EVENT_MAYBE_ATTENDING - o EVENT_NOT_ATTENDING + o EVENT_NOT_ATTENDING """ params = self._get_params() @@ -1735,7 +1735,7 @@ class Event(_BaseObject): v = doc.getElementsByTagName("venue")[0] venue_id = _number(_extract(v, "id")) - return Venue(venue_id, self.network, venue_element=v) + return Venue(venue_id, self.network) def get_start_date(self): """Returns the date when the event starts.""" @@ -1781,7 +1781,7 @@ class Event(_BaseObject): return _number(_extract(doc, "reviews")) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1794,15 +1794,15 @@ class Event(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ return self.network._get_url(domain_name, "event") %{'id': self.get_id()} def share(self, users, message = None): - """Shares this event (sends out recommendations). + """Shares this event (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -1919,7 +1919,7 @@ class Country(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1932,7 +1932,7 @@ class Country(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ country_name = _url_safe(self.get_name()) @@ -1976,7 +1976,7 @@ class Library(_BaseObject): """Add an album to this library.""" params = self._get_params() - params["artist"] = album.get_artist.get_name() + params["artist"] = album.get_artist().get_name() params["album"] = album.get_name() self._request("library.addAlbum", False, params) @@ -2173,7 +2173,7 @@ class Playlist(_BaseObject): return _extract(self._get_info_node(), "image")[size] def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the playlist on the network. + """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2186,7 +2186,7 @@ class Playlist(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ english_url = _extract(self._get_info_node(), "url") @@ -2319,7 +2319,7 @@ class Tag(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the tag page on the network. + """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2332,7 +2332,7 @@ class Tag(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2548,9 +2548,9 @@ class Track(_BaseObject, _Taggable): return seq def share(self, users, message = None): - """Shares this track (sends out recommendations). + """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, or all of them. - * message: A message to include in the recommendation message. + * message: A message to include in the recommendation message. """ #last.fm currently accepts a max of 10 recipient at a time @@ -2575,7 +2575,7 @@ class Track(_BaseObject, _Taggable): self._request('track.share', False, params) def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the track page on the network. + """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2588,7 +2588,7 @@ class Track(_BaseObject, _Taggable): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) @@ -2707,7 +2707,7 @@ class Group(_BaseObject): return seq def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the group page on the network. + """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2720,7 +2720,7 @@ class Group(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -2832,13 +2832,13 @@ class User(_BaseObject): for e_id in ids: events.append(Event(e_id, self.network)) - + return events - + def get_artist_tracks(self, artist): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" # Not implemented: "Can be limited to specific timeranges, defaults to all time." - + params = self._get_params() params['artist'] = artist @@ -2848,14 +2848,14 @@ class User(_BaseObject): artist = _extract(track, "artist") date = _extract(track, "date") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - + seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) return seq def get_friends(self, limit = 50): """Returns a list of the user's friends. """ - + seq = [] for node in _collect_nodes(limit, self, "user.getFriends", False): seq.append(User(_extract(node, "name"), self.network)) @@ -3037,13 +3037,13 @@ class User(_BaseObject): return _number(_extract(doc, "playcount")) def get_top_albums(self, period = PERIOD_OVERALL): - """Returns the top albums played by a user. + """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3062,13 +3062,13 @@ class User(_BaseObject): return seq def get_top_artists(self, period = PERIOD_OVERALL): - """Returns the top artists played by a user. + """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3086,8 +3086,8 @@ class User(_BaseObject): return seq def get_top_tags(self, limit=None): - """Returns a sequence of the top tags used by this user with their counts as TopItem objects. - * limit: The limit of how many tags to return. + """Returns a sequence of the top tags used by this user with their counts as TopItem objects. + * limit: The limit of how many tags to return. """ doc = self._request("user.getTopTags", True) @@ -3102,13 +3102,13 @@ class User(_BaseObject): return seq def get_top_tracks(self, period = PERIOD_OVERALL): - """Returns the top tracks played by a user. + """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS o PERIOD_3MONTHS o PERIOD_6MONTHS - o PERIOD_12MONTHS + o PERIOD_12MONTHS """ params = self._get_params() @@ -3230,7 +3230,7 @@ class User(_BaseObject): return _extract(doc, "image") def get_url(self, domain_name = DOMAIN_ENGLISH): - """Returns the url of the user page on the network. + """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -3243,7 +3243,7 @@ class User(_BaseObject): o DOMAIN_TURKISH o DOMAIN_RUSSIAN o DOMAIN_JAPANESE - o DOMAIN_CHINESE + o DOMAIN_CHINESE """ name = _url_safe(self.get_name()) @@ -3459,25 +3459,13 @@ class Venue(_BaseObject): """A venue where events are held.""" # TODO: waiting for a venue.getInfo web service to use. - # TODO: As an intermediate use case, can pass the venue DOM element when using - # Event.get_venue() to populate the venue info, if the venue.getInfo API - # call becomes available this workaround should be removed id = None - info = None - name = None - location = None - url = None - def __init__(self, id, network, venue_element=None): + def __init__(self, id, network): _BaseObject.__init__(self, network) self.id = _number(id) - if venue_element is not None: - self.info = _extract_element_tree(venue_element) - self.name = self.info.get('name') - self.url = self.info.get('url') - self.location = self.info.get('location') def __repr__(self): return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) @@ -3497,21 +3485,6 @@ class Venue(_BaseObject): return self.id - def get_name(self): - """Returns the name of the venue.""" - - return self.name - - def get_url(self): - """Returns the URL of the venue page.""" - - return self.url - - def get_location(self): - """Returns the location of the venue (dictionary).""" - - return self.location - def get_upcoming_events(self): """Returns the upcoming events in this venue.""" @@ -3602,14 +3575,14 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): total_pages = _number(main.getAttribute("totalpages")) else: raise Exception("No total pages attribute") - + for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): nodes.append(node) - + if page >= total_pages: end_of_pages = True - + page += 1 return nodes @@ -3625,35 +3598,6 @@ def _extract(node, name, index = 0): else: return None -def _extract_element_tree(node, index = 0): - """Extract an element tree into a multi-level dictionary - - NB: If any elements have text nodes as well as nested - elements this will ignore the text nodes""" - - def _recurse_build_tree(rootNode, targetDict): - """Recursively build a multi-level dict""" - - def _has_child_elements(rootNode): - """Check if an element has any nested (child) elements""" - - for node in rootNode.childNodes: - if node.nodeType == node.ELEMENT_NODE: - return True - return False - - for node in rootNode.childNodes: - if node.nodeType == node.ELEMENT_NODE: - if _has_child_elements(node): - targetDict[node.tagName] = {} - _recurse_build_tree(node, targetDict[node.tagName]) - else: - val = None if node.firstChild is None else _unescape_htmlentity(node.firstChild.data.strip()) - targetDict[node.tagName] = val - return targetDict - - return _recurse_build_tree(node, {}) - def _extract_all(node, name, limit_count = None): """Extracts all the values from the xml string. returning a list.""" @@ -3689,7 +3633,7 @@ def _number(string): def _unescape_htmlentity(string): - #string = _unicode(string) + #string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: diff --git a/setup.py b/setup.py index 7bde338..50654e0 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from distutils.core import setup import os def get_build(): path = "./.build" - + if os.path.exists(path): fp = open(path, "r") build = eval(fp.read()) @@ -14,19 +14,19 @@ def get_build(): fp.close() else: build = 1 - + fp = open(path, "w") fp.write(str(build)) fp.close() - + return str(build) setup(name = "pylast", - version = "0.6." + get_build(), + version = "0.1+0.5." + get_build(), author = "Amr Hassan ", description = "A Python interface to Last.fm (and other API compatible social networks)", author_email = "amr.hassan@gmail.com", - url = "https://github.com/inversion/", + url = "https://github.com/Elizacat/", py_modules = ("pylast",), license = "Apache2" ) From 16482faed16b89f8aff573053e1ade38c3538333 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 13:41:35 +0200 Subject: [PATCH 017/768] Add library.removeScrobble --- pylast.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pylast.py b/pylast.py index db824c3..3fd490d 100644 --- a/pylast.py +++ b/pylast.py @@ -2059,6 +2059,21 @@ class Library(_BaseObject): return seq + def remove_scrobble(self, artist, title, timestamp): + """Remove a scrobble from a user's Last.fm library. Parameters: + artist (Required) : The artist that composed the track + title (Required) : The name of the track + timestamp (Required) : The unix timestamp of the scrobble + that you wish to remove + """ + + params = self._get_params() + params["artist"] = artist + params["track"] = title + params["timestamp"] = timestamp + + self._request("library.removeScrobble", False, params) + class Playlist(_BaseObject): """A Last.fm user playlist.""" From d0e4ac0e0f22503254e58da5d9f498c494910281 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 19:55:37 +0200 Subject: [PATCH 018/768] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7bde338..908dd0d 100755 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ setup(name = "pylast", author = "Amr Hassan ", description = "A Python interface to Last.fm (and other API compatible social networks)", author_email = "amr.hassan@gmail.com", - url = "https://github.com/inversion/", + url = "https://github.com/hugovk/", py_modules = ("pylast",), license = "Apache2" ) From fc28f21983f47e61a56a9f203e4d67676f0c7b5f Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 19:55:48 +0200 Subject: [PATCH 019/768] Update .build --- .build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.build b/.build index da2d398..60d3b2f 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -14 \ No newline at end of file +15 From b1b1186b06092cc5a62cbd3793f7b3017babf31c Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 19:57:27 +0200 Subject: [PATCH 020/768] Add .gitignore --- .gitignore | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76f2a46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +# Sphinx documentation +docs/_build/ + From 4074c4fae998ac1bb6f49bb47b34f4890dc90532 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 20:22:07 +0200 Subject: [PATCH 021/768] Add integration tests for pylast.py --- test_pylast.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 test_pylast.py diff --git a/test_pylast.py b/test_pylast.py new file mode 100644 index 0000000..0942d3b --- /dev/null +++ b/test_pylast.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import datetime +import time +import unittest + +import pylast + +class TestSequenceFunctions(unittest.TestCase): + + def setUp(self): + self.username = "TODO" + password_hash = "TODO" + + API_KEY = "TODO" + API_SECRET = "TODO" + + self.network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = + API_SECRET, username = self.username, password_hash = password_hash) + + + def test_scrobble(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + timestamp = int(time.mktime(datetime.datetime.now().timetuple())) + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.scrobble(artist = artist, title = title, timestamp = timestamp) + + # Assert + last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] + self.assertEqual(str(last_scrobble.track.artist), str(artist)) + self.assertEqual(str(last_scrobble.track.title), str(title)) + self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + + + def test_unscrobble(self): + # Arrange + artist = "Test Artist 2" + title = "Test Title 2" + timestamp = int(time.mktime(datetime.datetime.now().timetuple())) + library = pylast.Library(user = self.username, network = self.network) + self.network.scrobble(artist = artist, title = title, timestamp = timestamp) + lastfm_user = self.network.get_user(self.username) + + # Act + library.remove_scrobble(artist = artist, title = title, timestamp = timestamp) + + # Assert + last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] + self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) + + + def test_add_album(self): + # Arrange + library = pylast.Library(user = self.username, network = self.network) + album = self.network.get_album("Test Artist", "Test Album") + + # Act + library.add_album(album) + + # Assert + # Nothing here, just that no exception occurred + + + def test_get_venue(self): + # Arrange + venue_name = "Last.fm Office" + country_name = "United Kingom" + + # Act + venue_search = self.network.search_for_venue(venue_name, country_name) + venue = venue_search.get_next_page()[0] + + # Assert + self.assertEqual(str(venue.id), "8778225") + + +if __name__ == '__main__': + unittest.main() + +# End of file From 1c1f56a62d8dcc4a285a925b815e306e9bf604a9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 26 Feb 2014 20:26:08 +0200 Subject: [PATCH 022/768] Remove unix timestamp duplication --- test_pylast.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 0942d3b..0ff9a9d 100644 --- a/test_pylast.py +++ b/test_pylast.py @@ -10,6 +10,9 @@ import pylast class TestSequenceFunctions(unittest.TestCase): + def unix_timestamp(self): + return int(time.mktime(datetime.datetime.now().timetuple())) + def setUp(self): self.username = "TODO" password_hash = "TODO" @@ -25,7 +28,7 @@ class TestSequenceFunctions(unittest.TestCase): # Arrange artist = "Test Artist" title = "Test Title" - timestamp = int(time.mktime(datetime.datetime.now().timetuple())) + timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) # Act @@ -42,7 +45,7 @@ class TestSequenceFunctions(unittest.TestCase): # Arrange artist = "Test Artist 2" title = "Test Title 2" - timestamp = int(time.mktime(datetime.datetime.now().timetuple())) + timestamp = self.unix_timestamp() library = pylast.Library(user = self.username, network = self.network) self.network.scrobble(artist = artist, title = title, timestamp = timestamp) lastfm_user = self.network.get_user(self.username) From 552c45f18f37c801add8d954518786d69b417a2f Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 27 Feb 2014 20:14:32 +0200 Subject: [PATCH 023/768] Get user's registration date (and as a UNIX timestamp) --- pylast.py | 18 ++++++++++++++-- test_pylast.py | 56 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/pylast.py b/pylast.py index 2671419..e608434 100644 --- a/pylast.py +++ b/pylast.py @@ -2063,7 +2063,7 @@ class Library(_BaseObject): """Remove a scrobble from a user's Last.fm library. Parameters: artist (Required) : The artist that composed the track title (Required) : The name of the track - timestamp (Required) : The unix timestamp of the scrobble + timestamp (Required) : The unix timestamp of the scrobble that you wish to remove """ @@ -2830,7 +2830,7 @@ class User(_BaseObject): return {"user": self.get_name()} def get_name(self, properly_capitalized=False): - """Returns the nuser name.""" + """Returns the user name.""" if properly_capitalized: self.name = _extract(self._request("user.getInfo", True), "name") @@ -3051,6 +3051,20 @@ class User(_BaseObject): return _number(_extract(doc, "playcount")) + def get_registered(self): + """Returns the user's registration date.""" + + doc = self._request("user.getInfo", True) + + return _extract(doc, "registered") + + def get_unixtime_registered(self): + """Returns the user's registration date as a UNIX timestamp.""" + + doc = self._request("user.getInfo", True) + + return doc.getElementsByTagName("registered")[0].getAttribute("unixtime") + def get_top_albums(self, period = PERIOD_OVERALL): """Returns the top albums played by a user. * period: The period of time. Possible values: diff --git a/test_pylast.py b/test_pylast.py index 0ff9a9d..f522ed4 100644 --- a/test_pylast.py +++ b/test_pylast.py @@ -20,7 +20,7 @@ class TestSequenceFunctions(unittest.TestCase): API_KEY = "TODO" API_SECRET = "TODO" - self.network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = + self.network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = API_SECRET, username = self.username, password_hash = password_hash) @@ -30,17 +30,17 @@ class TestSequenceFunctions(unittest.TestCase): title = "Test Title" timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) - +# # Act self.network.scrobble(artist = artist, title = title, timestamp = timestamp) - +# # Assert last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] self.assertEqual(str(last_scrobble.track.artist), str(artist)) self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - - +# +# def test_unscrobble(self): # Arrange artist = "Test Artist 2" @@ -49,40 +49,66 @@ class TestSequenceFunctions(unittest.TestCase): library = pylast.Library(user = self.username, network = self.network) self.network.scrobble(artist = artist, title = title, timestamp = timestamp) lastfm_user = self.network.get_user(self.username) - +# # Act library.remove_scrobble(artist = artist, title = title, timestamp = timestamp) - +# # Assert last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) - - +# +# def test_add_album(self): # Arrange library = pylast.Library(user = self.username, network = self.network) album = self.network.get_album("Test Artist", "Test Album") - +# # Act library.add_album(album) - +# # Assert # Nothing here, just that no exception occurred - - +# +# def test_get_venue(self): # Arrange venue_name = "Last.fm Office" country_name = "United Kingom" - +# # Act venue_search = self.network.search_for_venue(venue_name, country_name) venue = venue_search.get_next_page()[0] - +# # Assert self.assertEqual(str(venue.id), "8778225") + def test_get_user_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + registered = user.get_registered() + + # Assert + # Just check date because of timezones + self.assertIn(u"2002-11-20 ", registered) + + + def test_get_user_unixtime_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + unixtime_registered = user.get_unixtime_registered() + + # Assert + # Just check date because of timezones + self.assertEquals(unixtime_registered, u"1037793040") + + if __name__ == '__main__': unittest.main() From ab2b605c8f8a2492a2e81b2b7df173ad92c97a53 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 27 Feb 2014 20:16:29 +0200 Subject: [PATCH 024/768] Remove pointless empty comments --- test_pylast.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index f522ed4..dc2a562 100644 --- a/test_pylast.py +++ b/test_pylast.py @@ -30,17 +30,17 @@ class TestSequenceFunctions(unittest.TestCase): title = "Test Title" timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) -# + # Act self.network.scrobble(artist = artist, title = title, timestamp = timestamp) -# + # Assert last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] self.assertEqual(str(last_scrobble.track.artist), str(artist)) self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) -# -# + + def test_unscrobble(self): # Arrange artist = "Test Artist 2" @@ -49,36 +49,36 @@ class TestSequenceFunctions(unittest.TestCase): library = pylast.Library(user = self.username, network = self.network) self.network.scrobble(artist = artist, title = title, timestamp = timestamp) lastfm_user = self.network.get_user(self.username) -# + # Act library.remove_scrobble(artist = artist, title = title, timestamp = timestamp) -# + # Assert last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) -# -# + + def test_add_album(self): # Arrange library = pylast.Library(user = self.username, network = self.network) album = self.network.get_album("Test Artist", "Test Album") -# + # Act library.add_album(album) -# + # Assert # Nothing here, just that no exception occurred -# -# + + def test_get_venue(self): # Arrange venue_name = "Last.fm Office" country_name = "United Kingom" -# + # Act venue_search = self.network.search_for_venue(venue_name, country_name) venue = venue_search.get_next_page()[0] -# + # Assert self.assertEqual(str(venue.id), "8778225") From 6d5c818ad003d85149249ca510cb407424ba8e11 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 28 Feb 2014 11:20:01 +0200 Subject: [PATCH 025/768] Fix up some missing stuff after grafting --- pylast.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pylast.py b/pylast.py index e608434..edf8184 100644 --- a/pylast.py +++ b/pylast.py @@ -33,6 +33,7 @@ import tempfile import sys import collections import warnings +import re def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -313,6 +314,37 @@ class _Network(object): return Playlist(user, e_id, self) + def get_top_artists(self, limit=None): + """Returns a sequence of the most played artists.""" + + doc = _Request(self, "chart.getTopArtists").execute(True) + seq = [] + for node in doc.getElementsByTagName("artist"): + title = _extract(node, "name") + artist = Artist(title, self) + seq.append(artist) + + if limit: + seq = seq[:limit] + + return seq + + def get_top_tracks(self, limit=None): + """Returns a sequence of the most played tracks.""" + + doc = _Request(self, "chart.getTopTracks").execute(True) + seq = [] + for node in doc.getElementsByTagName("track"): + title = _extract(node, "name") + artist = _extract(node, "name", 1) + track = Track(artist, title, self) + seq.append(track) + + if limit: + seq = seq[:limit] + + return seq + def get_top_tags(self, limit=None): """Returns a sequence of the most used tags as a sequence of TopItem objects.""" @@ -352,6 +384,8 @@ class _Network(object): def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. + In choosing the backend used for caching, it will try _SqliteCacheBackend first if + the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. @@ -814,6 +848,10 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) + # Pretty decent catch for invalid & characters - which Last.fm + # seems to generate for some artist eg. "K'nann" + response_text = re.sub("&(?![^\W]+;)", "&", response_text) + self._check_response_for_errors(response_text) return response_text From 56f3666cedb9c2fc6b6f9a704e8b74e34b3808e3 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 12:36:35 +0200 Subject: [PATCH 026/768] If no country, return None rather than Country class. Fixes http://stackoverflow.com/q/14609467/724176 --- pylast.py | 9 +++++++-- test_pylast.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index edf8184..ef1a89d 100644 --- a/pylast.py +++ b/pylast.py @@ -404,7 +404,7 @@ class _Network(object): def is_caching_enabled(self): """Returns True if caching is enabled.""" - return not (self.cache_backend == None) + return not (self.cache_backend is None) def _get_cache_backend(self): @@ -3052,7 +3052,12 @@ class User(_BaseObject): doc = self._request("user.getInfo", True) - return Country(_extract(doc, "country"), self.network) + country = _extract(doc, "country") + + if country is None: + return None + else: + return Country(country, self.network) def get_age(self): """Returns the user's age.""" diff --git a/test_pylast.py b/test_pylast.py index dc2a562..fb30c7c 100644 --- a/test_pylast.py +++ b/test_pylast.py @@ -108,6 +108,26 @@ class TestSequenceFunctions(unittest.TestCase): # Just check date because of timezones self.assertEquals(unixtime_registered, u"1037793040") + def test_get_genderless_user(self): + # Arrange + lastfm_user = self.network.get_user("test_user") # currently no gender set + + # Act + gender = lastfm_user.get_gender() + + # Assert + self.assertIsNone(gender) + + + def test_get_countryless_user(self): + # Arrange + lastfm_user = self.network.get_user("test_user") # currently no country set + + # Act + country = lastfm_user.get_country() + + # Assert + self.assertIsNone(country) if __name__ == '__main__': unittest.main() From e7153965f09adae3ed0d70617848f7153e470286 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 12:59:07 +0200 Subject: [PATCH 027/768] Move secrets out to test_pylast.yaml and provide an example. --- .gitignore | 1 + requirements.txt | 1 + test_pylast.py | 21 +++++++++++++++++---- test_pylast_example.yaml | 4 ++++ 4 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 requirements.txt mode change 100644 => 100755 test_pylast.py create mode 100644 test_pylast_example.yaml diff --git a/.gitignore b/.gitignore index 76f2a46..a66d7f0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ coverage.xml # Sphinx documentation docs/_build/ +test_pylast.yaml diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c3726e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml diff --git a/test_pylast.py b/test_pylast.py old mode 100644 new mode 100755 index fb30c7c..2e64893 --- a/test_pylast.py +++ b/test_pylast.py @@ -5,20 +5,33 @@ Integration (not unit) tests for pylast.py import datetime import time import unittest +import yaml # pip install pyyaml import pylast +def load_secrets(): + with open("test_pylast.yaml", "r") as f: # see test_pylast_example.yaml + doc = yaml.load(f) + print doc + print doc["username"] + return doc + class TestSequenceFunctions(unittest.TestCase): + secrets = None + def unix_timestamp(self): return int(time.mktime(datetime.datetime.now().timetuple())) def setUp(self): - self.username = "TODO" - password_hash = "TODO" + if self.__class__.secrets is None: + self.__class__.secrets = load_secrets() - API_KEY = "TODO" - API_SECRET = "TODO" + self.username = self.__class__.secrets["username"] + password_hash = self.__class__.secrets["password_hash"] + + API_KEY = self.__class__.secrets["api_key"] + API_SECRET = self.__class__.secrets["api_secret"] self.network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = API_SECRET, username = self.username, password_hash = password_hash) diff --git a/test_pylast_example.yaml b/test_pylast_example.yaml new file mode 100644 index 0000000..a8fa045 --- /dev/null +++ b/test_pylast_example.yaml @@ -0,0 +1,4 @@ +username: TODO_ENTER_YOURS_HERE +password_hash: TODO_ENTER_YOURS_HERE +api_key: TODO_ENTER_YOURS_HERE +api_secret: TODO_ENTER_YOURS_HERE From 4ae2e085396340a5192493e6909a2a915daaca97 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 13:00:17 +0200 Subject: [PATCH 028/768] Remove debug prints --- test_pylast.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 2e64893..dbfa7df 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -12,8 +12,6 @@ import pylast def load_secrets(): with open("test_pylast.yaml", "r") as f: # see test_pylast_example.yaml doc = yaml.load(f) - print doc - print doc["username"] return doc class TestSequenceFunctions(unittest.TestCase): From 50bb692383cca794b28cbc78f6a271237a4056cf Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 15:57:00 +0200 Subject: [PATCH 029/768] Add track.unlove, closes #65 --- pylast.py | 5 +++++ test_pylast.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/pylast.py b/pylast.py index ef1a89d..304bff6 100644 --- a/pylast.py +++ b/pylast.py @@ -2560,6 +2560,11 @@ class Track(_BaseObject, _Taggable): self._request('track.love') + def unlove(self): + """Remove the track to the user's loved tracks. """ + + self._request('track.unlove') + def ban(self): """Ban this track from ever playing on the radio. """ diff --git a/test_pylast.py b/test_pylast.py index dbfa7df..f18037d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -119,6 +119,7 @@ class TestSequenceFunctions(unittest.TestCase): # Just check date because of timezones self.assertEquals(unixtime_registered, u"1037793040") + def test_get_genderless_user(self): # Arrange lastfm_user = self.network.get_user("test_user") # currently no gender set @@ -140,6 +141,40 @@ class TestSequenceFunctions(unittest.TestCase): # Assert self.assertIsNone(country) + + def test_love(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + track = pylast.Track(artist, title, self.network) + lastfm_user = self.network.get_user(self.username) + + # Act + track.love() + + # Assert + loved = lastfm_user.get_loved_tracks(limit = 1) + self.assertEqual(str(loved[0].track.artist), "Test Artist") + self.assertEqual(str(loved[0].track.title), "Test Title") + + def test_unlove(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + track = pylast.Track(artist, title, self.network) + lastfm_user = self.network.get_user(self.username) + track.love() + + # Act + track.unlove() + + # Assert + loved = lastfm_user.get_loved_tracks(limit = 1) + if len(loved): # OK to be empty but if not: + self.assertNotEqual(str(loved.track.artist), "Test Artist") + self.assertNotEqual(str(loved.track.title), "Test Title") + + if __name__ == '__main__': unittest.main() From 5324aacd8334709449306fd25f04875366c126fa Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 16:25:19 +0200 Subject: [PATCH 030/768] Apply typo patch, closes #85. --- pylast.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pylast.py b/pylast.py index 304bff6..51cb729 100644 --- a/pylast.py +++ b/pylast.py @@ -907,7 +907,7 @@ class SessionKeyGenerator(object): self.web_auth_tokens = {} def _get_web_auth_token(self): - """Retrieves a token from the network for web authentication. + """Retreives a token from the network for web authentication. The token then has to be authorized from getAuthURL before creating session. """ @@ -935,7 +935,7 @@ class SessionKeyGenerator(object): return url def get_web_auth_session_key(self, url): - """Retrieves the session key of a web authorization process by its url.""" + """Retreives the session key of a web authorization process by its url.""" if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] @@ -953,7 +953,7 @@ class SessionKeyGenerator(object): return doc.getElementsByTagName('key')[0].firstChild.data def get_session_key(self, username, password_hash): - """Retrieve a session key with a username and a md5 hash of the user's password.""" + """Retreive a session key with a username and a md5 hash of the user's password.""" params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) @@ -3431,7 +3431,7 @@ class _Search(_BaseObject): return doc.getElementsByTagName(self._ws_prefix + "matches")[0] - def _retrieve_next_page(self): + def _retreive_next_page(self): self._last_page_index += 1 return self._retreive_page(self._last_page_index) @@ -3445,7 +3445,7 @@ class AlbumSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" - master_node = self._retrieve_next_page() + master_node = self._retreive_next_page() seq = [] for node in master_node.getElementsByTagName("album"): @@ -3462,7 +3462,7 @@ class ArtistSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" - master_node = self._retrieve_next_page() + master_node = self._retreive_next_page() seq = [] for node in master_node.getElementsByTagName("artist"): @@ -3482,7 +3482,7 @@ class TagSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Tag objects.""" - master_node = self._retrieve_next_page() + master_node = self._retreive_next_page() seq = [] for node in master_node.getElementsByTagName("tag"): @@ -3503,7 +3503,7 @@ class TrackSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - master_node = self._retrieve_next_page() + master_node = self._retreive_next_page() seq = [] for node in master_node.getElementsByTagName("track"): @@ -3524,7 +3524,7 @@ class VenueSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - master_node = self._retrieve_next_page() + master_node = self._retreive_next_page() seq = [] for node in master_node.getElementsByTagName("venue"): From 0d483234d4a364e1f5b77bbb37558bad5d76fb31 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 16:28:40 +0200 Subject: [PATCH 031/768] Add test case for bug already fixed by @inversion in https://github.com/hugovk/pylast/commit/c8216a139e216f016773de3eb92a1044ea919c1e. Closes #64. --- test_pylast.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index f18037d..073e3be 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -14,7 +14,7 @@ def load_secrets(): doc = yaml.load(f) return doc -class TestSequenceFunctions(unittest.TestCase): +class TestPyLast(unittest.TestCase): secrets = None @@ -157,6 +157,7 @@ class TestSequenceFunctions(unittest.TestCase): self.assertEqual(str(loved[0].track.artist), "Test Artist") self.assertEqual(str(loved[0].track.title), "Test Title") + def test_unlove(self): # Arrange artist = "Test Artist" @@ -175,7 +176,34 @@ class TestSequenceFunctions(unittest.TestCase): self.assertNotEqual(str(loved.track.title), "Test Title") + def test_get_100_albums(self): + # Arrange + library = pylast.Library(user = self.username, network = self.network) + + # Act + albums = library.get_albums(limit = 100) + + # Assert + self.assertGreaterEqual(len(albums), 0) + + + def test_get_limitless_albums(self): + # Arrange + library = pylast.Library(user = self.username, network = self.network) + + # Act + albums = library.get_albums(limit = None) + + # Assert + self.assertGreaterEqual(len(albums), 0) + + if __name__ == '__main__': + +# suite = unittest.TestSuite() +# suite.addTest(TestPyLast('test_get_limitless_albums')) +# unittest.TextTestRunner().run(suite) + unittest.main() # End of file From f59d2cc6809809c867cc7f1866a5585919d8e317 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 16:44:13 +0200 Subject: [PATCH 032/768] Fix comparison of User to other types. Closes #75. --- pylast.py | 10 ++++++++-- test_pylast.py | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 51cb729..12b703a 100644 --- a/pylast.py +++ b/pylast.py @@ -2864,10 +2864,16 @@ class User(_BaseObject): return self.get_name() def __eq__(self, another): - return self.get_name() == another.get_name() + if isinstance(another, User): + return self.get_name() == another.get_name() + else: + return False def __ne__(self, another): - return self.get_name() != another.get_name() + if isinstance(another, User): + return self.get_name() != another.get_name() + else: + return True def _get_params(self): return {"user": self.get_name()} diff --git a/test_pylast.py b/test_pylast.py index 073e3be..7f8ef72 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -198,10 +198,33 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(albums), 0) + def test_user_equals_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user == None) + + # Assert + self.assertFalse(value) + + + def test_user_not_equal_to_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user != None) + + # Assert + self.assertTrue(value) + + if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_get_limitless_albums')) +# suite.addTest(TestPyLast('test_user_equals_none')) +# suite.addTest(TestPyLast('test_user_not_equal_to_none')) # unittest.TextTestRunner().run(suite) unittest.main() From 16079468ab47f7b2183dfc48773a2da75738012b Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 16:57:29 +0200 Subject: [PATCH 033/768] Fix getting now playing for user with no scrobbles. Closes #81. --- pylast.py | 7 ++++++- test_pylast.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 12b703a..d388962 100644 --- a/pylast.py +++ b/pylast.py @@ -3002,7 +3002,12 @@ class User(_BaseObject): doc = self._request('user.getRecentTracks', False, params) - e = doc.getElementsByTagName('track')[0] + tracks = doc.getElementsByTagName('track') + + if len(tracks) == 0: + return None + + e = tracks[0] if not e.hasAttribute('nowplaying'): return None diff --git a/test_pylast.py b/test_pylast.py index 7f8ef72..230605a 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -220,11 +220,21 @@ class TestPyLast(unittest.TestCase): self.assertTrue(value) + def test_now_playing_user_with_no_scrobbles(self): + # Arrange + user = self.network.get_user('test-account') # currently has no scrobbles + + # Act + current_track = user.get_now_playing() + + # Assert + self.assertIsNone(current_track) + + if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_user_equals_none')) -# suite.addTest(TestPyLast('test_user_not_equal_to_none')) +# suite.addTest(TestPyLast('test_now_playing_user_with_no_scrobbles')) # unittest.TextTestRunner().run(suite) unittest.main() From 0d67c1e30952992fe4e75f1dc2d78410973a12db Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 17:10:28 +0200 Subject: [PATCH 034/768] Test case to verify #66 is fixed by #64. Closes #66. --- test_pylast.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 230605a..58406d6 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -231,10 +231,21 @@ class TestPyLast(unittest.TestCase): self.assertIsNone(current_track) + def test_love_limits(self): + # Arrange + user = self.network.get_user("test-user") # currently at least 23 loved tracks + + # Act/Assert + self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) + self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + + if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_now_playing_user_with_no_scrobbles')) +# suite.addTest(TestPyLast('test_love_limits')) # unittest.TextTestRunner().run(suite) unittest.main() From 880e160f64d020dfa3c247c5ea6e2fb904a14a09 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 17:43:25 +0200 Subject: [PATCH 035/768] Attempt to reproduce #77 but cannot reproduce. Closes #77. --- test_pylast.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 58406d6..fbf96dd 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -242,10 +242,28 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + def test_update_now_playing(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + album = "Test Album" + track_number = 1 + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.update_now_playing(artist = artist, title = title, album = album, track_number = track_number) + + # Assert + current_track = lastfm_user.get_now_playing() + self.assertIsNotNone(current_track) + self.assertEqual(str(current_track.title), "Test Title") + self.assertEqual(str(current_track.artist), "Test Artist") + + if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_love_limits')) +# suite.addTest(TestPyLast('test_update_now_playing')) # unittest.TextTestRunner().run(suite) unittest.main() From 7eb640fa88cfdda7c8360f363497c885568dd5bc Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 23:41:44 +0200 Subject: [PATCH 036/768] Add library.removeArtist, closes #84 --- pylast.py | 11 ++++++++++- test_pylast.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index d388962..6a87d78 100644 --- a/pylast.py +++ b/pylast.py @@ -2019,6 +2019,15 @@ class Library(_BaseObject): self._request("library.addAlbum", False, params) + def remove_album(self, album): + """Remove an album from this library.""" + + params = self._get_params() + params["artist"] = album.get_artist().get_name() + params["album"] = album.get_name() + + self._request("library.removeAlbum", False, params) + def add_artist(self, artist): """Add an artist to this library.""" @@ -3932,7 +3941,7 @@ class Scrobbler(object): def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): - _deprecation_warning("DeprecationWarning: Use Netowrk.update_now_playing(...) instead") + _deprecation_warning("DeprecationWarning: Use Network.update_now_playing(...) instead") params = {"s": self._get_session_id(), "a": artist, "t": title, "b": album, "l": duration, "n": track_number, "m": mbid} diff --git a/test_pylast.py b/test_pylast.py index fbf96dd..4119dde 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -78,7 +78,30 @@ class TestPyLast(unittest.TestCase): library.add_album(album) # Assert - # Nothing here, just that no exception occurred + my_albums = library.get_albums() + for my_album in my_albums: + value = (album == my_album[0]) + if value: + break + self.assertTrue(value) + + + def test_remove_album(self): + # Arrange + library = pylast.Library(user = self.username, network = self.network) + album = self.network.get_album("Test Artist", "Test Album") + library.add_album(album) + + # Act + library.remove_album(album) + + # Assert + my_albums = library.get_albums() + for my_album in my_albums: + value = (album == my_album[0]) + if value: + break + self.assertFalse(value) def test_get_venue(self): @@ -260,10 +283,12 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.artist), "Test Artist") + + if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_update_now_playing')) +# suite.addTest(TestPyLast('test_remove_album')) # unittest.TextTestRunner().run(suite) unittest.main() From bdb429cc8aea786ea6dbcd4d2d1c574f64bf47b1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 1 Mar 2014 23:54:45 +0200 Subject: [PATCH 037/768] Add test for adding artist, and allow artist as Artist or string --- pylast.py | 5 ++++- test_pylast.py | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 6a87d78..63c1fbb 100644 --- a/pylast.py +++ b/pylast.py @@ -2032,7 +2032,10 @@ class Library(_BaseObject): """Add an artist to this library.""" params = self._get_params() - params["artist"] = artist.get_name() + if type(artist) == str: + params["artist"] = artist + else: + params["artist"] = artist.get_name() self._request("library.addArtist", False, params) diff --git a/test_pylast.py b/test_pylast.py index 4119dde..b07da94 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -104,7 +104,23 @@ class TestPyLast(unittest.TestCase): self.assertFalse(value) - def test_get_venue(self): + def test_add_artist(self): + # Arrange + artist = "Test Artist 2" + library = pylast.Library(user = self.username, network = self.network) + + # Act + library.add_artist(artist) + + # Assert + artists = library.get_artists() + for artist in artists: + value = (str(artist[0]) == "Test Artist 2") + if value: + break + self.assertTrue(value) + + # Arrange venue_name = "Last.fm Office" country_name = "United Kingom" @@ -288,7 +304,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_remove_album')) +# suite.addTest(TestPyLast('test_add_artist')) # unittest.TextTestRunner().run(suite) unittest.main() From 3e5e371b2975300708e9b49c3e8f7b5ace5d27d4 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 00:04:30 +0200 Subject: [PATCH 038/768] Really add library.removeArtist, closes #84 --- pylast.py | 11 +++++++++++ test_pylast.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/pylast.py b/pylast.py index 63c1fbb..0c40b49 100644 --- a/pylast.py +++ b/pylast.py @@ -2039,6 +2039,17 @@ class Library(_BaseObject): self._request("library.addArtist", False, params) + def remove_artist(self, artist): + """Remove an artist from this library.""" + + params = self._get_params() + if type(artist) == str: + params["artist"] = artist + else: + params["artist"] = artist.get_name() + + self._request("library.removeArtist", False, params) + def add_track(self, track): """Add a track to this library.""" diff --git a/test_pylast.py b/test_pylast.py index b07da94..9655242 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -121,6 +121,25 @@ class TestPyLast(unittest.TestCase): self.assertTrue(value) + def test_remove_artist(self): + # Arrange + artist = "Test Artist 2" + library = pylast.Library(user = self.username, network = self.network) + library.add_artist(artist) + + # Act + library.remove_artist(artist) + + # Assert + artists = library.get_artists() + for artist in artists: + value = (str(artist[0]) == "Test Artist 2") + if value: + break + self.assertFalse(value) + + + def test_get_venue(self): # Arrange venue_name = "Last.fm Office" country_name = "United Kingom" @@ -305,6 +324,7 @@ if __name__ == '__main__': # suite = unittest.TestSuite() # suite.addTest(TestPyLast('test_add_artist')) +# suite.addTest(TestPyLast('test_remove_artist')) # unittest.TextTestRunner().run(suite) unittest.main() From e388db1e2f04f9d464acd4b83ee987962c3d8f9e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 00:25:29 +0200 Subject: [PATCH 039/768] ws_server for LibreFMNetwork is now alpha.libre.fm, not alpha.dev.libre.fm. Closes #79. --- pylast.py | 28 ++++++++++++++-------------- test_pylast.py | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/pylast.py b/pylast.py index 0c40b49..2e12146 100644 --- a/pylast.py +++ b/pylast.py @@ -659,8 +659,8 @@ class LibreFMNetwork(_Network): _Network.__init__(self, name = "Libre.fm", - homepage = "http://alpha.dev.libre.fm", - ws_server = ("alpha.dev.libre.fm", "/2.0/"), + homepage = "http://alpha.libre.fm", + ws_server = ("alpha.libre.fm", "/2.0/"), api_key = api_key, api_secret = api_secret, session_key = session_key, @@ -668,18 +668,18 @@ class LibreFMNetwork(_Network): username = username, password_hash = password_hash, domain_names = { - DOMAIN_ENGLISH: "alpha.dev.libre.fm", - DOMAIN_GERMAN: "alpha.dev.libre.fm", - DOMAIN_SPANISH: "alpha.dev.libre.fm", - DOMAIN_FRENCH: "alpha.dev.libre.fm", - DOMAIN_ITALIAN: "alpha.dev.libre.fm", - DOMAIN_POLISH: "alpha.dev.libre.fm", - DOMAIN_PORTUGUESE: "alpha.dev.libre.fm", - DOMAIN_SWEDISH: "alpha.dev.libre.fm", - DOMAIN_TURKISH: "alpha.dev.libre.fm", - DOMAIN_RUSSIAN: "alpha.dev.libre.fm", - DOMAIN_JAPANESE: "alpha.dev.libre.fm", - DOMAIN_CHINESE: "alpha.dev.libre.fm", + DOMAIN_ENGLISH: "alpha.libre.fm", + DOMAIN_GERMAN: "alpha.libre.fm", + DOMAIN_SPANISH: "alpha.libre.fm", + DOMAIN_FRENCH: "alpha.libre.fm", + DOMAIN_ITALIAN: "alpha.libre.fm", + DOMAIN_POLISH: "alpha.libre.fm", + DOMAIN_PORTUGUESE: "alpha.libre.fm", + DOMAIN_SWEDISH: "alpha.libre.fm", + DOMAIN_TURKISH: "alpha.libre.fm", + DOMAIN_RUSSIAN: "alpha.libre.fm", + DOMAIN_JAPANESE: "alpha.libre.fm", + DOMAIN_CHINESE: "alpha.libre.fm", }, urls = { "album": "artist/%(artist)s/album/%(album)s", diff --git a/test_pylast.py b/test_pylast.py index 9655242..602ac87 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -318,13 +318,25 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.artist), "Test Artist") + def test_libre_fm(self): + # Arrange + username = self.__class__.secrets["username"] + password_hash = self.__class__.secrets["password_hash"] + + # Act + network = pylast.LibreFMNetwork(password_hash = password_hash, username = username) + tags = network.get_top_tags() + + # Assert + print len(tags) + self.assertGreater(len(tags), 0) + self.assertTrue(type(tags[0]) == pylast.TopItem) if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_add_artist')) -# suite.addTest(TestPyLast('test_remove_artist')) +# suite.addTest(TestPyLast('test_libre_fm')) # unittest.TextTestRunner().run(suite) unittest.main() From 44318fccc492e590b425aed05ba3eafe5e06398e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 10:00:26 +0200 Subject: [PATCH 040/768] Album.get_top_tracks now returns TopItems. Closes #86. --- pylast.py | 15 --------------- test_pylast.py | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/pylast.py b/pylast.py index 2e12146..76d26fa 100644 --- a/pylast.py +++ b/pylast.py @@ -1282,21 +1282,6 @@ class Album(_BaseObject, _Taggable): return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) - def get_top_tags(self, limit=None): - """Returns a list of the most-applied tags to this album.""" - - doc = self._request("album.getInfo", True) - e = doc.getElementsByTagName("toptags")[0] - - seq = [] - for name in _extract_all(e, "name"): - seq.append(Tag(name, self.network)) - - if limit: - seq = seq[:limit] - - return seq - def get_tracks(self): """Returns the list of Tracks on this album.""" diff --git a/test_pylast.py b/test_pylast.py index 602ac87..1231567 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -57,6 +57,7 @@ class TestPyLast(unittest.TestCase): artist = "Test Artist 2" title = "Test Title 2" timestamp = self.unix_timestamp() + print timestamp library = pylast.Library(user = self.username, network = self.network) self.network.scrobble(artist = artist, title = title, timestamp = timestamp) lastfm_user = self.network.get_user(self.username) @@ -325,10 +326,21 @@ class TestPyLast(unittest.TestCase): # Act network = pylast.LibreFMNetwork(password_hash = password_hash, username = username) - tags = network.get_top_tags() + tags = network.get_top_tags(limit = 1) + + # Assert + self.assertGreater(len(tags), 0) + self.assertTrue(type(tags[0]) == pylast.TopItem) + + + def test_album_tags_are_topitems(self): + # Arrange + albums = self.network.get_user('RJ').get_top_albums() + + # Act + tags = albums[0].item.get_top_tags(limit = 1) # Assert - print len(tags) self.assertGreater(len(tags), 0) self.assertTrue(type(tags[0]) == pylast.TopItem) @@ -336,7 +348,8 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_libre_fm')) +# suite.addTest(TestPyLast('test_scrobble')) +# suite.addTest(TestPyLast('test_unscrobble')) # unittest.TextTestRunner().run(suite) unittest.main() From 0246fa45d580134401e01ac0ea93f29285291026 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 10:15:47 +0200 Subject: [PATCH 041/768] Simplify unix_timestamp() --- test_pylast.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 1231567..640e04c 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -2,7 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import datetime import time import unittest import yaml # pip install pyyaml @@ -19,7 +18,7 @@ class TestPyLast(unittest.TestCase): secrets = None def unix_timestamp(self): - return int(time.mktime(datetime.datetime.now().timetuple())) + return int(time.time()) def setUp(self): if self.__class__.secrets is None: From f4e1cb7448dfbee54770aa38fe8b933c7a6b6ac1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 10:28:44 +0200 Subject: [PATCH 042/768] Add test case for #82. Passes as expected with Python 2.7, but need to test in 3 --- test_pylast.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 640e04c..265a24e 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -344,13 +344,34 @@ class TestPyLast(unittest.TestCase): self.assertTrue(type(tags[0]) == pylast.TopItem) + def test_track_is_hashable(self): + # TODO same for some other types + # https://github.com/hugovk/pylast/issues/82 + # (passes in Python 2.7 but how about 3?) + + # Arrange + lastfm_user = self.network.get_user(self.username) + track = lastfm_user.get_recent_tracks(limit = 1)[0] + tracks = set() + + # Act + tracks.add(track) + + # Assert + self.assertIsNotNone(track) + self.assertEqual(len(tracks), 1) + + if __name__ == '__main__': -# suite = unittest.TestSuite() -# suite.addTest(TestPyLast('test_scrobble')) -# suite.addTest(TestPyLast('test_unscrobble')) -# unittest.TextTestRunner().run(suite) + # For quick testing of a single-case (eg. test = "test_track_is_hashable" + test = "" - unittest.main() + if test is not None and len(test): + suite = unittest.TestSuite() + suite.addTest(TestPyLast(test)) + unittest.TextTestRunner().run(suite) + else: + unittest.main() # End of file From 6b91e6f8e536edc8da75e43d4a6aa41a4e452c85 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 11:04:46 +0200 Subject: [PATCH 043/768] Add .travis.yml --- .travis.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7bb9826 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - "2.6" + - "2.7" + - "3.2" + - "3.3" +# command to install dependencies +install: "pip install -r requirements.txt" +# command to run tests +script: /test_pylast.py From 897d0a132c3a6273b73be5933d00ade005b6d5a0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 11:06:29 +0200 Subject: [PATCH 044/768] Fix command path --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7bb9826..2c7e2d3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,4 +7,4 @@ python: # command to install dependencies install: "pip install -r requirements.txt" # command to run tests -script: /test_pylast.py +script: ./test_pylast.py From baa4e782b6f2ca2e26bd3a442051e288190cb186 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 11:49:46 +0200 Subject: [PATCH 045/768] If no secrets file, load from environment variables. For Travis CI testing. --- .travis.yml | 18 +++++++++++------- test_pylast.py | 17 +++++++++++++---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2c7e2d3..778d5b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,14 @@ language: python python: - - "2.6" - - "2.7" - - "3.2" - - "3.3" -# command to install dependencies -install: "pip install -r requirements.txt" -# command to run tests +- '2.6' +- '2.7' +- '3.2' +- '3.3' +install: pip install -r requirements.txt script: ./test_pylast.py +env: + matrix: + - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= + - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= + - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= + - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= diff --git a/test_pylast.py b/test_pylast.py index 265a24e..c0cd926 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +import os import time import unittest import yaml # pip install pyyaml @@ -9,10 +10,19 @@ import yaml # pip install pyyaml import pylast def load_secrets(): - with open("test_pylast.yaml", "r") as f: # see test_pylast_example.yaml - doc = yaml.load(f) + secrets_file = "test_pylast.yaml" + if os.path.isfile(secrets_file): + with open(secrets_file, "r") as f: # see test_pylast_example.yaml + doc = yaml.load(f) + else: + doc = {} + doc["username"] = os.environ['PYLAST_USERNAME'].strip() + doc["password_hash"] = os.environ['PYLAST_PASSWORD_HASH'].strip() + doc["api_key"] = os.environ['PYLAST_API_KEY'].strip() + doc["api_secret"] = os.environ['PYLAST_API_SECRET'].strip() return doc + class TestPyLast(unittest.TestCase): secrets = None @@ -56,7 +66,6 @@ class TestPyLast(unittest.TestCase): artist = "Test Artist 2" title = "Test Title 2" timestamp = self.unix_timestamp() - print timestamp library = pylast.Library(user = self.username, network = self.network) self.network.scrobble(artist = artist, title = title, timestamp = timestamp) lastfm_user = self.network.get_user(self.username) @@ -364,7 +373,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': - # For quick testing of a single-case (eg. test = "test_track_is_hashable" + # For quick testing of a single-case (eg. test = "test_scrobble") test = "" if test is not None and len(test): From fa994f862a4c9b7921da2b77b29074b5d4a277ec Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 11:54:20 +0200 Subject: [PATCH 046/768] Fix Travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 778d5b8..edbb8e8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ python: install: pip install -r requirements.txt script: ./test_pylast.py env: - matrix: + global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= From f3c583359d0d972e9e5d86d12346cd0822cdefcc Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:04:44 +0200 Subject: [PATCH 047/768] Remove 2.6 and 3.2, keep 2.7 and 3.3. At least assertGreater() and some others are new in 2.7. --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index edbb8e8..0a85fbb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: -- '2.6' - '2.7' -- '3.2' - '3.3' install: pip install -r requirements.txt script: ./test_pylast.py From 7887242e845bd5ace5ed54d9496d2b1f88c39db0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:11:39 +0200 Subject: [PATCH 048/768] Add debugging for Python 3.3 --- pylast.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylast.py b/pylast.py index 76d26fa..13771f5 100644 --- a/pylast.py +++ b/pylast.py @@ -3045,6 +3045,7 @@ class User(_BaseObject): seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): + print track.toprettyxml() if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here @@ -3055,6 +3056,7 @@ class User(_BaseObject): seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + print seq return seq def get_id(self): From 892ccd3585e346ce490abf2aaadf54d1d0813313 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:13:50 +0200 Subject: [PATCH 049/768] Add debugging for Python 3.3 --- pylast.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 13771f5..12d399c 100644 --- a/pylast.py +++ b/pylast.py @@ -3045,7 +3045,8 @@ class User(_BaseObject): seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): - print track.toprettyxml() + print type(track) + print track if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here From e90f433e5eb77c4b4e3a6b5fb4e1b902222d6f89 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:17:22 +0200 Subject: [PATCH 050/768] Add debugging for Python 3.3 --- pylast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 12d399c..20ce68f 100644 --- a/pylast.py +++ b/pylast.py @@ -3045,8 +3045,9 @@ class User(_BaseObject): seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): - print type(track) - print track + print(type(track)) + print(track) + print(track.toprettyxml()) if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here From b83113bda55a544de429c0181f807c6dd366124f Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:19:22 +0200 Subject: [PATCH 051/768] Add debugging for Python 3.3 --- pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 20ce68f..4cc9a79 100644 --- a/pylast.py +++ b/pylast.py @@ -3058,7 +3058,7 @@ class User(_BaseObject): seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) - print seq + print(seq) return seq def get_id(self): From d125afd45f9ffd497bf89c7fe4ade7fb5eef1f35 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 12:28:14 +0200 Subject: [PATCH 052/768] When getting last scrobbled track, need to get at least two recent tracks because the last may be a now-playing and thus ignored. --- test_pylast.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index c0cd926..c829a1d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -55,7 +55,7 @@ class TestPyLast(unittest.TestCase): self.network.scrobble(artist = artist, title = title, timestamp = timestamp) # Assert - last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] + last_scrobble = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing self.assertEqual(str(last_scrobble.track.artist), str(artist)) self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) @@ -74,7 +74,7 @@ class TestPyLast(unittest.TestCase): library.remove_scrobble(artist = artist, title = title, timestamp = timestamp) # Assert - last_scrobble = lastfm_user.get_recent_tracks(limit = 1)[0] + last_scrobble = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) @@ -184,7 +184,7 @@ class TestPyLast(unittest.TestCase): # Assert # Just check date because of timezones - self.assertEquals(unixtime_registered, u"1037793040") + self.assertEqual(unixtime_registered, u"1037793040") def test_get_genderless_user(self): @@ -360,7 +360,7 @@ class TestPyLast(unittest.TestCase): # Arrange lastfm_user = self.network.get_user(self.username) - track = lastfm_user.get_recent_tracks(limit = 1)[0] + track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing tracks = set() # Act From 6b47ae15b7f8e8f49dd61fe234d7bd7e08fc8f99 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 13:12:14 +0200 Subject: [PATCH 053/768] Add possible partial fix for #82 --- pylast.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pylast.py b/pylast.py index 4cc9a79..c34a49f 100644 --- a/pylast.py +++ b/pylast.py @@ -2406,6 +2406,7 @@ class Track(_BaseObject, _Taggable): artist = None title = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): _BaseObject.__init__(self, network) @@ -3045,9 +3046,6 @@ class User(_BaseObject): seq = [] for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): - print(type(track)) - print(track) - print(track.toprettyxml()) if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here @@ -3058,7 +3056,6 @@ class User(_BaseObject): seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) - print(seq) return seq def get_id(self): From 6caed64f75e5c58c796149c072fdabfe99e4e274 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:01:08 +0200 Subject: [PATCH 054/768] Use random album or artist to avoid problems running concurrent tests on CI --- test_pylast.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index c829a1d..24734df 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -3,6 +3,7 @@ Integration (not unit) tests for pylast.py """ import os +from random import choice import time import unittest import yaml # pip install pyyaml @@ -98,7 +99,11 @@ class TestPyLast(unittest.TestCase): def test_remove_album(self): # Arrange library = pylast.Library(user = self.username, network = self.network) - album = self.network.get_album("Test Artist", "Test Album") + # Pick an artist with plenty of albums + artist = self.network.get_top_artists()[0] + albums = artist.get_top_albums() + # Pick a random one to avoid problems running concurrent tests + album = choice(albums)[0] library.add_album(album) # Act @@ -132,17 +137,20 @@ class TestPyLast(unittest.TestCase): def test_remove_artist(self): # Arrange - artist = "Test Artist 2" + # Get plenty of artists + artists = self.network.get_top_artists() + # Pick a random one to avoid problems running concurrent tests + my_artist = choice(artists) library = pylast.Library(user = self.username, network = self.network) - library.add_artist(artist) + library.add_artist(my_artist) # Act - library.remove_artist(artist) + library.remove_artist(my_artist) # Assert artists = library.get_artists() for artist in artists: - value = (str(artist[0]) == "Test Artist 2") + value = (artist[0] == my_artist) if value: break self.assertFalse(value) @@ -374,7 +382,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # For quick testing of a single-case (eg. test = "test_scrobble") - test = "" + test = "test_remove_artist" if test is not None and len(test): suite = unittest.TestSuite() From 06a05361a52c603627528110e0bc2c45e813e13e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:01:33 +0200 Subject: [PATCH 055/768] Run all tests --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 24734df..f5eff44 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -382,7 +382,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # For quick testing of a single-case (eg. test = "test_scrobble") - test = "test_remove_artist" + test = "" if test is not None and len(test): suite = unittest.TestSuite() From 14293aacca95c5815e94ea95e1b66bab3fc9b365 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:09:57 +0200 Subject: [PATCH 056/768] Add test for hashable user. Should fail on 3.3. / cc #82 --- test_pylast.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index f5eff44..db14355 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -362,10 +362,6 @@ class TestPyLast(unittest.TestCase): def test_track_is_hashable(self): - # TODO same for some other types - # https://github.com/hugovk/pylast/issues/82 - # (passes in Python 2.7 but how about 3?) - # Arrange lastfm_user = self.network.get_user(self.username) track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing @@ -379,6 +375,23 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tracks), 1) + def test_user_is_hashable(self): + # TODO same for some other types + # https://github.com/hugovk/pylast/issues/82 + # (passes in Python 2.7 but how about 3?) + + # Arrange + lastfm_user = self.network.get_user(self.username) + users = set() + + # Act + users.add(lastfm_user) + + # Assert + self.assertIsNotNone(lastfm_user) + self.assertEqual(len(users), 1) + + if __name__ == '__main__': # For quick testing of a single-case (eg. test = "test_scrobble") From 6fa52100c306f4d488220282ec3ab4b7530ca00d Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:12:47 +0200 Subject: [PATCH 057/768] Should fix hashable user on 3.3, for #82 --- pylast.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pylast.py b/pylast.py index c34a49f..cfa7325 100644 --- a/pylast.py +++ b/pylast.py @@ -2855,6 +2855,7 @@ class User(_BaseObject): """A Last.fm user.""" name = None + __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network): _BaseObject.__init__(self, network) From 1b5a09b4048b41ed472a55f3bafab938fad1e871 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:31:28 +0200 Subject: [PATCH 058/768] Add build status --- README => README.md | 2 ++ 1 file changed, 2 insertions(+) rename README => README.md (58%) diff --git a/README b/README.md similarity index 58% rename from README rename to README.md index 923259e..4719102 100644 --- a/README +++ b/README.md @@ -1,6 +1,8 @@ pylast ------ +[![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) + A python interface to Last.fm. Try using the pydoc utility for help on usage. From 3fe197bbccc61b72fdd39d38a587000f6d4303c7 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 14:46:13 +0200 Subject: [PATCH 059/768] Hashable artist and album, for #82 --- .gitignore | 1 + pylast.py | 2 ++ test_pylast.py | 30 ++++++++++++++++++++++++++---- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a66d7f0..d547e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +htmlcov/ .tox/ .coverage .cache diff --git a/pylast.py b/pylast.py index cfa7325..bdf35e0 100644 --- a/pylast.py +++ b/pylast.py @@ -1189,6 +1189,7 @@ class Album(_BaseObject, _Taggable): title = None artist = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): """ @@ -1358,6 +1359,7 @@ class Artist(_BaseObject, _Taggable): name = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, name, network, username=None): """Create an artist object. diff --git a/test_pylast.py b/test_pylast.py index db14355..dab6c6f 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -361,6 +361,32 @@ class TestPyLast(unittest.TestCase): self.assertTrue(type(tags[0]) == pylast.TopItem) + def test_album_is_hashable(self): + # Arrange + album = self.network.get_album("Test Artist", "Test Album") + albums = set() + + # Act + albums.add(album) + + # Assert + self.assertIsNotNone(album) + self.assertEqual(len(albums), 1) + + + def test_artist_is_hashable(self): + # Arrange + artist = self.network.get_artist("Test Artist") + artists = set() + + # Act + artists.add(artist) + + # Assert + self.assertIsNotNone(artist) + self.assertEqual(len(artists), 1) + + def test_track_is_hashable(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -376,10 +402,6 @@ class TestPyLast(unittest.TestCase): def test_user_is_hashable(self): - # TODO same for some other types - # https://github.com/hugovk/pylast/issues/82 - # (passes in Python 2.7 but how about 3?) - # Arrange lastfm_user = self.network.get_user(self.username) users = set() From c3bf1f0a06a63e93ab181f8e8c610f0ae4ad1326 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 18:35:40 +0200 Subject: [PATCH 060/768] Add tests for hashable events and tags, for #82 --- test_pylast.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index dab6c6f..8a62980 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -387,6 +387,33 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(artists), 1) + def test_event_is_hashable(self): + # Arrange + user = self.network.get_user("RJ") + event = user.get_past_events(limit = 1)[0] + events = set() + + # Act + events.add(event) + + # Assert + self.assertIsNotNone(event) + self.assertEqual(len(events), 1) + + + def test_tag_is_hashable(self): + # Arrange + tag = self.network.get_top_tags(limit = 1)[0] + tags = set() + + # Act + tags.add(tag) + + # Assert + self.assertIsNotNone(tag) + self.assertEqual(len(tags), 1) + + def test_track_is_hashable(self): # Arrange lastfm_user = self.network.get_user(self.username) From 3257a2b178816eed544cbd019be21db8e68ded10 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 18:40:47 +0200 Subject: [PATCH 061/768] Events and tags are now hashable --- pylast.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pylast.py b/pylast.py index bdf35e0..611c605 100644 --- a/pylast.py +++ b/pylast.py @@ -1189,6 +1189,7 @@ class Album(_BaseObject, _Taggable): title = None artist = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): @@ -1359,6 +1360,7 @@ class Artist(_BaseObject, _Taggable): name = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, name, network, username=None): @@ -1674,6 +1676,8 @@ class Event(_BaseObject): id = None + __hash__ = _BaseObject.__hash__ + def __init__(self, event_id, network): _BaseObject.__init__(self, network) @@ -2265,6 +2269,8 @@ class Tag(_BaseObject): name = None + __hash__ = _BaseObject.__hash__ + def __init__(self, name, network): _BaseObject.__init__(self, network) @@ -2408,6 +2414,7 @@ class Track(_BaseObject, _Taggable): artist = None title = None username = None + __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): @@ -2857,6 +2864,7 @@ class User(_BaseObject): """A Last.fm user.""" name = None + __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network): From d0c80c8b4e6c160167229947c8833b0af28cdbb1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 18:55:32 +0200 Subject: [PATCH 062/768] Attempt coverage with coveralls --- .travis.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0a85fbb..768a9e6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,12 @@ language: python python: - '2.7' - '3.3' -install: pip install -r requirements.txt -script: ./test_pylast.py +install: +- pip install -r requirements.txt +- pip install coveralls +script: coverage run --source=pylast ./test_pylast.py +after_success: + coveralls env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= From d6ef6e1384771c2c773d5402e3cd870d6012ea5b Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 19:01:27 +0200 Subject: [PATCH 063/768] Add Coveralls coverage badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4719102..fb1c7ff 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pylast ------ -[![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) +[![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) [![Coverage Status](https://coveralls.io/repos/hugovk/pylast/badge.png?branch=master)](https://coveralls.io/r/hugovk/pylast?branch=master) A python interface to Last.fm. Try using the pydoc utility for help on usage. From 9d5d56ceafbd65bd7fa431cc74c058351c389cc8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 19:34:55 +0200 Subject: [PATCH 064/768] Country and Venue now hashable, for #82 --- pylast.py | 9 ++++++++- test_pylast.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 611c605..4d122a4 100644 --- a/pylast.py +++ b/pylast.py @@ -1011,8 +1011,11 @@ class _BaseObject(object): return {} def __hash__(self): + # Convert any ints (or whatever) into strings + values = map(str, self._get_params().values()) + return hash(self.network) + \ - hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(self._get_params().values())).lower()) + hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower()) class _Taggable(object): """Common functions for classes with tags.""" @@ -1885,6 +1888,8 @@ class Country(_BaseObject): name = None + __hash__ = _BaseObject.__hash__ + def __init__(self, name, network): _BaseObject.__init__(self, network) @@ -3577,6 +3582,8 @@ class Venue(_BaseObject): location = None url = None + __hash__ = _BaseObject.__hash__ + def __init__(self, id, network, venue_element=None): _BaseObject.__init__(self, network) diff --git a/test_pylast.py b/test_pylast.py index 8a62980..3676ce1 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -387,6 +387,19 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(artists), 1) + def test_country_is_hashable(self): + # Arrange + country = pylast.Country("Italy", self.network) + countries = set() + + # Act + countries.add(country) + + # Assert + self.assertIsNotNone(country) + self.assertEqual(len(countries), 1) + + def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") @@ -441,9 +454,23 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(users), 1) + def test_venue_is_hashable(self): + # Arrange + venue_id = "8778225" # Last.fm office + venue = pylast.Venue(venue_id, self.network) + venues = set() + + # Act + venues.add(venue) + + # Assert + self.assertIsNotNone(venue) + self.assertEqual(len(venues), 1) + + if __name__ == '__main__': - # For quick testing of a single-case (eg. test = "test_scrobble") + # For quick testing of a single case (eg. test = "test_scrobble") test = "" if test is not None and len(test): From 6db87f8a27809d4a2d87aec3b76a5391dca48b34 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 20:17:34 +0200 Subject: [PATCH 065/768] Library, Playlist, XSPF and Group now also hashable, with a helper function to reduce test duplication. For #82. --- pylast.py | 8 ++++ test_pylast.py | 111 +++++++++++++++++++++++++------------------------ 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/pylast.py b/pylast.py index 4d122a4..1de47b1 100644 --- a/pylast.py +++ b/pylast.py @@ -1979,6 +1979,8 @@ class Library(_BaseObject): user = None + __hash__ = _BaseObject.__hash__ + def __init__(self, user, network): _BaseObject.__init__(self, network) @@ -2138,6 +2140,8 @@ class Playlist(_BaseObject): id = None user = None + __hash__ = _BaseObject.__hash__ + def __init__(self, user, id, network): _BaseObject.__init__(self, network) @@ -2697,6 +2701,8 @@ class Group(_BaseObject): name = None + __hash__ = _BaseObject.__hash__ + def __init__(self, group_name, network): _BaseObject.__init__(self, network) @@ -2828,6 +2834,8 @@ class XSPF(_BaseObject): uri = None + __hash__ = _BaseObject.__hash__ + def __init__(self, uri, network): _BaseObject.__init__(self, network) diff --git a/test_pylast.py b/test_pylast.py index 3676ce1..d0baf03 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -361,111 +361,114 @@ class TestPyLast(unittest.TestCase): self.assertTrue(type(tags[0]) == pylast.TopItem) + def helper_is_thing_hashable(self, thing): + # Arrange + things = set() + + # Act + things.add(thing) + + # Assert + self.assertIsNotNone(thing) + self.assertEqual(len(things), 1) + def test_album_is_hashable(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") - albums = set() - # Act - albums.add(album) - - # Assert - self.assertIsNotNone(album) - self.assertEqual(len(albums), 1) + # Act/Assert + self.helper_is_thing_hashable(album) def test_artist_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") - artists = set() - # Act - artists.add(artist) - - # Assert - self.assertIsNotNone(artist) - self.assertEqual(len(artists), 1) + # Act/Assert + self.helper_is_thing_hashable(artist) def test_country_is_hashable(self): # Arrange country = pylast.Country("Italy", self.network) - countries = set() - # Act - countries.add(country) - - # Assert - self.assertIsNotNone(country) - self.assertEqual(len(countries), 1) + # Act/Assert + self.helper_is_thing_hashable(country) def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") event = user.get_past_events(limit = 1)[0] - events = set() - # Act - events.add(event) + # Act/Assert + self.helper_is_thing_hashable(event) - # Assert - self.assertIsNotNone(event) - self.assertEqual(len(events), 1) + + def test_group_is_hashable(self): + # Arrange + group = pylast.Group("Audioscrobbler Beta", self.network) + + # Act/Assert + self.helper_is_thing_hashable(group) + + + def test_library_is_hashable(self): + # Arrange + library = pylast.Library(user = self.username, network = self.network) + + # Act/Assert + self.helper_is_thing_hashable(library) + + + def test_playlist_is_hashable(self): + # Arrange + playlist = pylast.Playlist(user = "RJ", id = "1k1qp_doglist", network = self.network) + + # Act/Assert + self.helper_is_thing_hashable(playlist) def test_tag_is_hashable(self): # Arrange tag = self.network.get_top_tags(limit = 1)[0] - tags = set() - # Act - tags.add(tag) - - # Assert - self.assertIsNotNone(tag) - self.assertEqual(len(tags), 1) + # Act/Assert + self.helper_is_thing_hashable(tag) def test_track_is_hashable(self): # Arrange lastfm_user = self.network.get_user(self.username) track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing - tracks = set() - # Act - tracks.add(track) - - # Assert - self.assertIsNotNone(track) - self.assertEqual(len(tracks), 1) + # Act/Assert + self.helper_is_thing_hashable(track) def test_user_is_hashable(self): # Arrange lastfm_user = self.network.get_user(self.username) - users = set() - # Act - users.add(lastfm_user) - - # Assert - self.assertIsNotNone(lastfm_user) - self.assertEqual(len(users), 1) + # Act/Assert + self.helper_is_thing_hashable(lastfm_user) def test_venue_is_hashable(self): # Arrange venue_id = "8778225" # Last.fm office venue = pylast.Venue(venue_id, self.network) - venues = set() - # Act - venues.add(venue) + # Act/Assert + self.helper_is_thing_hashable(venue) - # Assert - self.assertIsNotNone(venue) - self.assertEqual(len(venues), 1) + + def test_xspf_is_hashable(self): + # Arrange + xspf = pylast.XSPF(uri = "lastfm://playlist/1k1qp_doglist", network = self.network) + + # Act/Assert + self.helper_is_thing_hashable(xspf) if __name__ == '__main__': From c6c8d45635a4d62b4f53d23da2b3ba138a6609e7 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 22:54:02 +0200 Subject: [PATCH 066/768] Apply patch to fix illegal XML characters, closes #71 --- pylast.py | 16 +++++++++++++--- test_pylast.py | 13 +++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 1de47b1..6a5bb4c 100644 --- a/pylast.py +++ b/pylast.py @@ -114,6 +114,16 @@ SCROBBLE_MODE_LOVED = "L" SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" +# From http://boodebr.org/main/python/all-about-python-and-unicode#UNI_XML +RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \ + u'|' + \ + u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \ + (unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), + unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), + unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff)) + +XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) + class _Network(object): """ A music social network website that is Last.fm or one exposing a Last.fm compatible API @@ -848,9 +858,7 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) - # Pretty decent catch for invalid & characters - which Last.fm - # seems to generate for some artist eg. "K'nann" - response_text = re.sub("&(?![^\W]+;)", "&", response_text) + response_text = XML_ILLEGAL.sub("?", response_text) self._check_response_for_errors(response_text) return response_text @@ -4045,3 +4053,5 @@ class Scrobbler(object): if remainder: self.scrobble_many(remainder) + +# End of file diff --git a/test_pylast.py b/test_pylast.py index d0baf03..8925284 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -471,6 +471,19 @@ class TestPyLast(unittest.TestCase): self.helper_is_thing_hashable(xspf) + def test_invalid_xml(self): + # Arrange + # Currently causes PCDATA invalid Char value 25 + artist = "Blind Willie Johnson" + title = "It's nobody's fault but mine" + + # Act + search = self.network.search_for_track(artist, title) + total = search.get_total_result_count() + + # Assert + self.assertGreaterEqual(total, 0) + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 2d861dcb10f6217cc97642cc7eed94b546b9f96e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 2 Mar 2014 22:58:30 +0200 Subject: [PATCH 067/768] Cast string of total to int, for #71 --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 8925284..16ead83 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -482,7 +482,7 @@ class TestPyLast(unittest.TestCase): total = search.get_total_result_count() # Assert - self.assertGreaterEqual(total, 0) + self.assertGreaterEqual(int(total), 0) if __name__ == '__main__': From c57afec43fb629d6251c65dba753f7f4be174755 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 08:52:06 +0200 Subject: [PATCH 068/768] Info from Google Code page plus how to test --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fb1c7ff..d9320db 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,87 @@ -pylast ------- +pyLast +====== [![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) [![Coverage Status](https://coveralls.io/repos/hugovk/pylast/badge.png?branch=master)](https://coveralls.io/r/hugovk/pylast?branch=master) -A python interface to Last.fm. Try using the pydoc utility for help -on usage. +A Python interface to Last.fm (and other api-compatible websites such as Libre.fm). -Original code can be found at: http://code.google.com/p/pylast/ +Try using the pydoc utility for help on usage or see test_pylast.py for examples. + +Original code can be found at http://code.google.com/p/pylast/ but hasn't been updated since 2011. + +Features +-------- + + * Simple public interface. + * Access to all the data exposed by the Last.fm web services. + * Scrobbling support. + * Full object-oriented design. + * Proxy support. + * Internal caching support for some web services calls (disabled by default). + * No extra dependencies but Python itself. + * Support for other API-compatible networks like Libre.fm. + * Python 3-friendly (Starting from 0.5). + + +Getting Started +--------------- + +Here's a simple code example to get you started. In order to create any object from pyLast, you need a Network object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: + +``` +import pylast + +# You have to have your own unique two values for API_KEY and API_SECRET +# Obtain yours from http://www.last.fm/api/account for Last.fm +API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key +API_SECRET = "425b55975eed76058ac220b7b4e8a054" + +# In order to perform a write operation you need to authenticate yourself +username = "your_user_name" +password_hash = pylast.md5("your_password") + +network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = + API_SECRET, username = username, password_hash = password_hash) + +# now you can use that object everywhere +artist = network.get_artist("System of a Down") +artist.shout("<3") + + +track = network.get_track("Iron Maiden", "The Nomad") +track.love() +track.add_tags(("awesome", "favorite")) + +# type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter to get more help +# about anything and see examples of how it works +``` + +Testing +------- + +test_pylast.py contains integration tests with Last.fm, and plenty of code examples. + +You need a test account at Last.fm that can be cluttered with test data, and an API key and secret. Either copy test_pylast_example.yaml to test_pylast.yaml and fill out the credentials, or set them as environment variables like: + +``` +export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE +export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE +export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE +export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE +``` + +Then: +``` +pip install pyyaml +./test_pylast.py +``` + +To run with coverage: +``` +pip install +pip install coverage +coverage run --source=pylast ./test_pylast.py +coverage report # for command-line report +coverage html # for HTML report +open htmlcov/index.html +``` From 954e32a819de7ed352492499ec2c62ecd13dc0e1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 08:59:36 +0200 Subject: [PATCH 069/768] Rename test_pylast_example.yaml -> example_test_pylast.yaml (makes tab-autocompletion happier) --- README.md | 6 +++--- test_pylast_example.yaml => example_test_pylast.yaml | 0 test_pylast.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename test_pylast_example.yaml => example_test_pylast.yaml (100%) diff --git a/README.md b/README.md index d9320db..051b4fe 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ API_SECRET = "425b55975eed76058ac220b7b4e8a054" username = "your_user_name" password_hash = pylast.md5("your_password") -network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = +network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = API_SECRET, username = username, password_hash = password_hash) # now you can use that object everywhere @@ -61,7 +61,7 @@ Testing test_pylast.py contains integration tests with Last.fm, and plenty of code examples. -You need a test account at Last.fm that can be cluttered with test data, and an API key and secret. Either copy test_pylast_example.yaml to test_pylast.yaml and fill out the credentials, or set them as environment variables like: +You need a test account at Last.fm that can be cluttered with test data, and an API key and secret. Either copy example_test_pylast.yaml to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ``` export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE @@ -78,7 +78,7 @@ pip install pyyaml To run with coverage: ``` -pip install +pip install pip install coverage coverage run --source=pylast ./test_pylast.py coverage report # for command-line report diff --git a/test_pylast_example.yaml b/example_test_pylast.yaml similarity index 100% rename from test_pylast_example.yaml rename to example_test_pylast.yaml diff --git a/test_pylast.py b/test_pylast.py index 16ead83..c41a87b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -6,14 +6,14 @@ import os from random import choice import time import unittest -import yaml # pip install pyyaml import pylast def load_secrets(): secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): - with open(secrets_file, "r") as f: # see test_pylast_example.yaml + import yaml # pip install pyyaml + with open(secrets_file, "r") as f: # see example_test_pylast.yaml doc = yaml.load(f) else: doc = {} From 023828c07a1cf3dfc05b5f6134223544ddecb5b0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 09:04:40 +0200 Subject: [PATCH 070/768] Fix indentation --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index c41a87b..bcc351b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -12,7 +12,7 @@ import pylast def load_secrets(): secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): - import yaml # pip install pyyaml + import yaml # pip install pyyaml with open(secrets_file, "r") as f: # see example_test_pylast.yaml doc = yaml.load(f) else: From 3a0e69f218ea246558d75baaffedc71adb9d3242 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 12:16:22 +0200 Subject: [PATCH 071/768] Add test case for #70: username parameter already supported for track.getInfo to get user's play count --- test_pylast.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index bcc351b..397620e 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -484,6 +484,20 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(int(total), 0) + + def test_user_play_count_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + track = pylast.Track(artist = artist, title = title, network = self.network, username = self.username) + + # Act + count = track.get_userplaycount() + + # Assert + self.assertGreaterEqual(count, 0) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 785139dce6291a6353dc5ee90b1770cf17fa4f2f Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 12:45:16 +0200 Subject: [PATCH 072/768] Add Track.get_userloved() with test, closes #70 --- pylast.py | 18 +++++++++++++++--- test_pylast.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 6a5bb4c..46db7e6 100644 --- a/pylast.py +++ b/pylast.py @@ -903,7 +903,7 @@ class SessionKeyGenerator(object): c. password_hash = pylast.md5(raw_input("Please enter your password: ") d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) - A session key's lifetime is infinie, unless the user provokes the rights of the given API Key. + A session key's lifetime is infinite, unless the user provokes the rights of the given API Key. If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this @@ -1291,7 +1291,7 @@ class Album(_BaseObject, _Taggable): return _number(_extract(self._request("album.getInfo", True, params), "userplaycount")) def get_listener_count(self): - """Returns the number of liteners on the network""" + """Returns the number of listeners on the network""" return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) @@ -1448,7 +1448,7 @@ class Artist(_BaseObject, _Taggable): return _extract(doc, "mbid") def get_listener_count(self): - """Returns the number of liteners on the network.""" + """Returns the number of listeners on the network.""" if hasattr(self, "listener_count"): return self.listener_count @@ -2529,6 +2529,18 @@ class Track(_BaseObject, _Taggable): doc = self._request("track.getInfo", True, params) return _number(_extract(doc, "userplaycount")) + def get_userloved(self): + """Whether the user loved this track""" + + if not self.username: return + + params = self._get_params() + params['username'] = self.username + + doc = self._request("track.getInfo", True, params) + loved = _number(_extract(doc, "userloved")) + return bool(loved) + def is_streamable(self): """Returns True if the track is available at Last.fm.""" diff --git a/test_pylast.py b/test_pylast.py index 397620e..1a7221d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -498,6 +498,21 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(count, 0) + def test_user_loved_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "Test Title" + track = pylast.Track(artist = artist, title = title, network = self.network, username = self.username) + + # Act + loved = track.get_userloved() + + # Assert + self.assertIsNotNone(loved) + self.assertIsInstance(loved, bool) + self.assertNotIsInstance(loved, str) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 896e248909d84eb916fa58d3425e6979e89a0d2c Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 12:48:58 +0200 Subject: [PATCH 073/768] Unfix broken fix for #85 --- pylast.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/pylast.py b/pylast.py index 46db7e6..8a9ae14 100644 --- a/pylast.py +++ b/pylast.py @@ -422,33 +422,33 @@ class _Network(object): def search_for_album(self, album_name): """Searches for an album by its name. Returns a AlbumSearch object. - Use get_next_page() to retreive sequences of results.""" + Use get_next_page() to retrieve sequences of results.""" return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): """Searches of an artist by its name. Returns a ArtistSearch object. - Use get_next_page() to retreive sequences of results.""" + Use get_next_page() to retrieve sequences of results.""" return ArtistSearch(artist_name, self) def search_for_tag(self, tag_name): """Searches of a tag by its name. Returns a TagSearch object. - Use get_next_page() to retreive sequences of results.""" + Use get_next_page() to retrieve sequences of results.""" return TagSearch(tag_name, self) def search_for_track(self, artist_name, track_name): """Searches of a track by its name and its artist. Set artist to an empty string if not available. Returns a TrackSearch object. - Use get_next_page() to retreive sequences of results.""" + Use get_next_page() to retrieve sequences of results.""" return TrackSearch(artist_name, track_name, self) def search_for_venue(self, venue_name, country_name): """Searches of a venue by its name and its country. Set country_name to an empty string if not available. Returns a VenueSearch object. - Use get_next_page() to retreive sequences of results.""" + Use get_next_page() to retrieve sequences of results.""" return VenueSearch(venue_name, country_name, self) @@ -915,7 +915,7 @@ class SessionKeyGenerator(object): self.web_auth_tokens = {} def _get_web_auth_token(self): - """Retreives a token from the network for web authentication. + """Retrieves a token from the network for web authentication. The token then has to be authorized from getAuthURL before creating session. """ @@ -943,7 +943,7 @@ class SessionKeyGenerator(object): return url def get_web_auth_session_key(self, url): - """Retreives the session key of a web authorization process by its url.""" + """Retrieves the session key of a web authorization process by its url.""" if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] @@ -961,7 +961,7 @@ class SessionKeyGenerator(object): return doc.getElementsByTagName('key')[0].firstChild.data def get_session_key(self, username, password_hash): - """Retreive a session key with a username and a md5 hash of the user's password.""" + """Retrieve a session key with a username and a md5 hash of the user's password.""" params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) @@ -3486,7 +3486,7 @@ class _Search(_BaseObject): return _extract(doc, "opensearch:totalResults") - def _retreive_page(self, page_index): + def _retrieve_page(self, page_index): """Returns the node of matches to be processed""" params = self._get_params() @@ -3495,9 +3495,9 @@ class _Search(_BaseObject): return doc.getElementsByTagName(self._ws_prefix + "matches")[0] - def _retreive_next_page(self): + def _retrieve_next_page(self): self._last_page_index += 1 - return self._retreive_page(self._last_page_index) + return self._retrieve_page(self._last_page_index) class AlbumSearch(_Search): """Search for an album by name.""" @@ -3509,7 +3509,7 @@ class AlbumSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" - master_node = self._retreive_next_page() + master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("album"): @@ -3526,7 +3526,7 @@ class ArtistSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" - master_node = self._retreive_next_page() + master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("artist"): @@ -3546,7 +3546,7 @@ class TagSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Tag objects.""" - master_node = self._retreive_next_page() + master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("tag"): @@ -3567,7 +3567,7 @@ class TrackSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - master_node = self._retreive_next_page() + master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("track"): @@ -3588,7 +3588,7 @@ class VenueSearch(_Search): def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" - master_node = self._retreive_next_page() + master_node = self._retrieve_next_page() seq = [] for node in master_node.getElementsByTagName("venue"): From a38f3b485c722ec87d7ae3f0910cff3fec0d5ca6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 13:16:23 +0200 Subject: [PATCH 074/768] Add album for a user's recent tracks and artist tracks, closes #69 --- pylast.py | 8 +++++--- test_pylast.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 8a9ae14..af77dff 100644 --- a/pylast.py +++ b/pylast.py @@ -977,7 +977,7 @@ class SessionKeyGenerator(object): TopItem = collections.namedtuple("TopItem", ["item", "weight"]) SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) -PlayedTrack = collections.namedtuple("PlayedTrack", ["track", "playback_date", "timestamp"]) +PlayedTrack = collections.namedtuple("PlayedTrack", ["track", "album", "playback_date", "timestamp"]) LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) ImageSizes = collections.namedtuple("ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"]) Image = collections.namedtuple("Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"]) @@ -2964,9 +2964,10 @@ class User(_BaseObject): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") + album = _extract(track, "album") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + seq.append(PlayedTrack(Track(artist, title, self.network), album, date, timestamp)) return seq @@ -3096,9 +3097,10 @@ class User(_BaseObject): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") + album = _extract(track, "album") timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(PlayedTrack(Track(artist, title, self.network), date, timestamp)) + seq.append(PlayedTrack(Track(artist, title, self.network), album, date, timestamp)) return seq diff --git a/test_pylast.py b/test_pylast.py index 1a7221d..bc4f82c 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -513,6 +513,28 @@ class TestPyLast(unittest.TestCase): self.assertNotIsInstance(loved, str) + def test_album_in_recent_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing + + # Assert + self.assertTrue(hasattr(track, 'album')) + + + def test_album_in_artist_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + track = lastfm_user.get_artist_tracks(artist = "Test Artist")[0] + + # Assert + self.assertTrue(hasattr(track, 'album')) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From a1867cc8d4bde41e17f9d5edf12461cdb1f555a9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 13:41:10 +0200 Subject: [PATCH 075/768] Make rate-limiting a switch that can be toggled, closes #80 --- pylast.py | 27 ++++++++++++++++++++------- test_pylast.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/pylast.py b/pylast.py index af77dff..5677e51 100644 --- a/pylast.py +++ b/pylast.py @@ -169,6 +169,7 @@ class _Network(object): self.proxy_enabled = False self.proxy = None self.last_call_time = 0 + self.limit_rate = False #generate a session_key if necessary if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): @@ -288,21 +289,21 @@ class _Network(object): def _get_ws_auth(self): """ - Returns a (API_KEY, API_SECRET, SESSION_KEY) tuple. + Returns an (API_KEY, API_SECRET, SESSION_KEY) tuple. """ return (self.api_key, self.api_secret, self.session_key) def _delay_call(self): """ - Makes sure that web service calls are at least a second apart + Makes sure that web service calls are at least 0.2 seconds apart. """ - # delay time in seconds - DELAY_TIME = 1.0 + # Delay time in seconds from section 4.4 of http://www.last.fm/api/tos + DELAY_TIME = 0.2 now = time.time() if (now - self.last_call_time) < DELAY_TIME: - time.sleep(1) + time.sleep(now - self.last_call_time - DELAY_TIME) self.last_call_time = now @@ -392,6 +393,18 @@ class _Network(object): return self.proxy + def enable_rate_limit(self): + """Enables rate limiting for this network""" + self.limit_rate = True + + def disable_rate_limit(self): + """Disables rate limiting for this network""" + self.limit_rate = False + + def is_rate_limited(self): + """Return True if web service calls are rate limited""" + return self.limit_rate + def enable_caching(self, file_path = None): """Enables caching request-wide for all cachable calls. In choosing the backend used for caching, it will try _SqliteCacheBackend first if @@ -820,8 +833,8 @@ class _Request(object): def _download_response(self): """Returns a response body string from the server.""" - # Delay the call if necessary - #self.network._delay_call() # enable it if you want. + if self.network.limit_rate: + self.network._delay_call() data = [] for name in self.params.keys(): diff --git a/test_pylast.py b/test_pylast.py index bc4f82c..6cdb5b4 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -535,6 +535,29 @@ class TestPyLast(unittest.TestCase): self.assertTrue(hasattr(track, 'album')) + def test_enable_rate_limiting(self): + # Arrange + self.assertFalse(self.network.is_rate_limited()) + + # Act + self.network.enable_rate_limit() + + # Assert + self.assertTrue(self.network.is_rate_limited()) + + + def test_disable_rate_limiting(self): + # Arrange + self.network.enable_rate_limit() + self.assertTrue(self.network.is_rate_limited()) + + # Act + self.network.disable_rate_limit() + + # Assert + self.assertFalse(self.network.is_rate_limited()) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 40ea12a22f8ad9cd441e1ae841e156e1e6460ba0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 14:57:15 +0200 Subject: [PATCH 076/768] Improve rate limiting and tests, for #80 --- pylast.py | 6 ++++-- test_pylast.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 5677e51..ee0c760 100644 --- a/pylast.py +++ b/pylast.py @@ -302,8 +302,10 @@ class _Network(object): DELAY_TIME = 0.2 now = time.time() - if (now - self.last_call_time) < DELAY_TIME: - time.sleep(now - self.last_call_time - DELAY_TIME) + time_since_last = now - self.last_call_time + + if time_since_last < DELAY_TIME: + time.sleep(DELAY_TIME - time_since_last) self.last_call_time = now diff --git a/test_pylast.py b/test_pylast.py index 6cdb5b4..188680f 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -541,9 +541,16 @@ class TestPyLast(unittest.TestCase): # Act self.network.enable_rate_limit() + then = time.time() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + now = time.time() # Assert self.assertTrue(self.network.is_rate_limited()) + self.assertGreaterEqual(now - then, 0.2) def test_disable_rate_limiting(self): @@ -553,6 +560,12 @@ class TestPyLast(unittest.TestCase): # Act self.network.disable_rate_limit() + then = time.time() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + now = time.time() # Assert self.assertFalse(self.network.is_rate_limited()) From c15923e4cd12724f770f38c5e5fd1ede4dd907ee Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 18:41:07 +0200 Subject: [PATCH 077/768] get_upcoming_events() no longer returns events with venue IDs. Also fixed/tested other events functions, and removed duplication. Closes #78. --- pylast.py | 36 ++++++------------- test_pylast.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/pylast.py b/pylast.py index ee0c760..3a9d2fc 100644 --- a/pylast.py +++ b/pylast.py @@ -1040,6 +1040,12 @@ class _BaseObject(object): return hash(self.network) + \ hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower()) + def _get_events_from_doc(self, doc): + events = [] + for node in doc.getElementsByTagName("event"): + events.append(Event(_extract(node, "id"), self.network)) + return events + class _Taggable(object): """Common functions for classes with tags.""" @@ -1508,13 +1514,7 @@ class Artist(_BaseObject, _Taggable): doc = self._request('artist.getEvents', True) - ids = _extract_all(doc, 'id') - - events = [] - for e_id in ids: - events.append(Event(e_id, self.network)) - - return events + return self._get_events_from_doc(doc) def get_similar(self, limit = None): """Returns the similar artists on the network.""" @@ -2955,17 +2955,11 @@ class User(_BaseObject): return self.name def get_upcoming_events(self): - """Returns all the upcoming events for this user. """ + """Returns all the upcoming events for this user.""" doc = self._request('user.getEvents', True) - ids = _extract_all(doc, 'id') - events = [] - - for e_id in ids: - events.append(Event(e_id, self.network)) - - return events + return self._get_events_from_doc(doc) def get_artist_tracks(self, artist): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" @@ -3677,22 +3671,14 @@ class Venue(_BaseObject): doc = self._request("venue.getEvents", True) - seq = [] - for node in doc.getElementsByTagName("event"): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq + return self._get_events_from_doc(doc) def get_past_events(self): """Returns the past events held in this venue.""" doc = self._request("venue.getEvents", True) - seq = [] - for node in doc.getElementsByTagName("event"): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq + return self._get_events_from_doc(doc) def md5(text): """Returns the md5 hash of a string.""" diff --git a/test_pylast.py b/test_pylast.py index 188680f..bbb133d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -159,7 +159,7 @@ class TestPyLast(unittest.TestCase): def test_get_venue(self): # Arrange venue_name = "Last.fm Office" - country_name = "United Kingom" + country_name = "United Kingdom" # Act venue_search = self.network.search_for_venue(venue_name, country_name) @@ -571,6 +571,97 @@ class TestPyLast(unittest.TestCase): self.assertFalse(self.network.is_rate_limited()) + # Commented out because (a) it'll take a long time and + # (b) it strangely fails due Last.fm's complaining of hitting the rate limit, + # even when limited to one call per second. The ToS allows 5 calls per second. + # def test_get_all_scrobbles(self): + # # Arrange + # lastfm_user = self.network.get_user("RJ") + # self.network.enable_rate_limit() # this is going to be slow... + + # # Act + # tracks = lastfm_user.get_recent_tracks(limit = None) + + # # Assert + # self.assertGreaterEqual(len(tracks), 0) + + + def helper_past_events_have_valid_ids(self, thing): + # Act + events = thing.get_past_events() + + # Assert + self.helper_assert_events_have_valid_ids(events) + + + def helper_upcoming_events_have_valid_ids(self, thing): + # Act + events = thing.get_upcoming_events() + + # Assert + self.helper_assert_events_have_valid_ids(events) + + + def helper_assert_events_have_valid_ids(self, events): + # Assert + self.assertGreaterEqual(len(events), 1) # if fails, add past/future event for user/Test Artist + for event in events[:2]: # checking first two should be enough + self.assertIsInstance(event.get_headliner(), pylast.Artist) + + + def test_artist_upcoming_events_returns_valid_ids(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act/Assert + self.helper_upcoming_events_have_valid_ids(artist) + + + def test_user_past_events_returns_valid_ids(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act/Assert + self.helper_past_events_have_valid_ids(lastfm_user) + + + def test_user_recommended_events_returns_valid_ids(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + events = lastfm_user.get_upcoming_events() + + # Assert + self.helper_assert_events_have_valid_ids(events) + + + def test_user_upcoming_events_returns_valid_ids(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act/Assert + self.helper_upcoming_events_have_valid_ids(lastfm_user) + + + def test_venue_past_events_returns_valid_ids(self): + # Arrange + venue_id = "8778225" # Last.fm office + venue = pylast.Venue(venue_id, self.network) + + # Act/Assert + self.helper_past_events_have_valid_ids(venue) + + + def test_venue_upcoming_events_returns_valid_ids(self): + # Arrange + venue_id = "8778225" # Last.fm office + venue = pylast.Venue(venue_id, self.network) + + # Act/Assert + self.helper_upcoming_events_have_valid_ids(venue) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From b9471799720bea4c96e388cc314ca8f68b9ac68f Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 21:54:35 +0200 Subject: [PATCH 078/768] Test pickle and cpickle on User object. Seems to work, so going to close #83 and can re-open if more specific info is given. --- .gitignore | 2 ++ test_pylast.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.gitignore b/.gitignore index d547e2c..e7288ae 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,6 @@ coverage.xml # Sphinx documentation docs/_build/ +# Test files test_pylast.yaml +lastfm.txt.pkl diff --git a/test_pylast.py b/test_pylast.py index bbb133d..cbc859b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -662,6 +662,34 @@ class TestPyLast(unittest.TestCase): self.helper_upcoming_events_have_valid_ids(venue) + def helper_test_pickle(self, pickle): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + pickle.dump(lastfm_user, open("lastfm.txt.pkl", "wb")) + loaded_user = pickle.load(open("lastfm.txt.pkl", "rb")) + + # Assert + self.assertEqual(lastfm_user, loaded_user) + + + def test_pickle(self): + # Arrange + import pickle + + # Act/Assert + self.helper_test_pickle(pickle) + + + def test_cpickle(self): + # Arrange + import cPickle as pickle + + # Act/Assert + self.helper_test_pickle(pickle) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 2d42d3b15d030a123b355b9ae649d1760ee9873d Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 22:22:29 +0200 Subject: [PATCH 079/768] No cPickle in Python 3, instead it uses whichever is available. So let's just test pickle --- test_pylast.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index cbc859b..ea7444d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -662,8 +662,9 @@ class TestPyLast(unittest.TestCase): self.helper_upcoming_events_have_valid_ids(venue) - def helper_test_pickle(self, pickle): + def test_pickle(self): # Arrange + import pickle lastfm_user = self.network.get_user(self.username) # Act @@ -674,22 +675,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(lastfm_user, loaded_user) - def test_pickle(self): - # Arrange - import pickle - - # Act/Assert - self.helper_test_pickle(pickle) - - - def test_cpickle(self): - # Arrange - import cPickle as pickle - - # Act/Assert - self.helper_test_pickle(pickle) - - if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From cd10d53a82587b48bfe133ffdf3fe64a142e398c Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Mar 2014 23:03:45 +0200 Subject: [PATCH 080/768] Partial fix for #87 --- pylast.py | 4 +++- test_pylast.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 3a9d2fc..379d40a 100644 --- a/pylast.py +++ b/pylast.py @@ -1496,7 +1496,9 @@ class Artist(_BaseObject, _Taggable): else: params = None - return _extract(self._request("artist.getInfo", True, params), "summary") + doc = self._request("artist.getInfo", True, params) + + return doc.getElementsByTagName('summary')[0].firstChild.wholeText.strip() def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" diff --git a/test_pylast.py b/test_pylast.py index ea7444d..3264728 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -675,6 +675,18 @@ class TestPyLast(unittest.TestCase): self.assertEqual(lastfm_user, loaded_user) + def test_bio_summary(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_summary() + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 9edfcb5745dacfa266ad17d0b87a24e125431e62 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 09:24:20 +0200 Subject: [PATCH 081/768] Extra tests for artist's bio, track/album's wiki content/summary. Closes #87. --- pylast.py | 21 ++++++++++-------- test_pylast.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/pylast.py b/pylast.py index 379d40a..149026f 100644 --- a/pylast.py +++ b/pylast.py @@ -1040,12 +1040,17 @@ class _BaseObject(object): return hash(self.network) + \ hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower()) - def _get_events_from_doc(self, doc): + def _extract_events_from_doc(self, doc): events = [] for node in doc.getElementsByTagName("event"): events.append(Event(_extract(node, "id"), self.network)) return events + def _extract_cdata_from_request(self, method_name, tag_name, params): + doc = self._request(method_name, True, params) + + return doc.getElementsByTagName(tag_name)[0].firstChild.wholeText.strip() + class _Taggable(object): """Common functions for classes with tags.""" @@ -1496,9 +1501,7 @@ class Artist(_BaseObject, _Taggable): else: params = None - doc = self._request("artist.getInfo", True, params) - - return doc.getElementsByTagName('summary')[0].firstChild.wholeText.strip() + return self._extract_cdata_from_request("artist.getInfo", "summary", params) def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" @@ -1509,14 +1512,14 @@ class Artist(_BaseObject, _Taggable): else: params = None - return _extract(self._request("artist.getInfo", True, params), "content") + return self._extract_cdata_from_request("artist.getInfo", "content", params) def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" doc = self._request('artist.getEvents', True) - return self._get_events_from_doc(doc) + return self._extract_events_from_doc(doc) def get_similar(self, limit = None): """Returns the similar artists on the network.""" @@ -2961,7 +2964,7 @@ class User(_BaseObject): doc = self._request('user.getEvents', True) - return self._get_events_from_doc(doc) + return self._extract_events_from_doc(doc) def get_artist_tracks(self, artist): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" @@ -3673,14 +3676,14 @@ class Venue(_BaseObject): doc = self._request("venue.getEvents", True) - return self._get_events_from_doc(doc) + return self._extract_events_from_doc(doc) def get_past_events(self): """Returns the past events held in this venue.""" doc = self._request("venue.getEvents", True) - return self._get_events_from_doc(doc) + return self._extract_events_from_doc(doc) def md5(text): """Returns the md5 hash of a string.""" diff --git a/test_pylast.py b/test_pylast.py index 3264728..4838f21 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -675,6 +675,18 @@ class TestPyLast(unittest.TestCase): self.assertEqual(lastfm_user, loaded_user) + def test_bio_content(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_content() + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + + def test_bio_summary(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -687,6 +699,54 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(bio), 1) + def test_album_wiki_content(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki_content() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + + def test_album_wiki_summary(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki_summary() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + + def test_track_wiki_content(self): + # Arrange + track = pylast.Track("Test Artist", "Test Title", self.network) + + # Act + wiki = track.get_wiki_content() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + + def test_track_wiki_summary(self): + # Arrange + track = pylast.Track("Test Artist", "Test Title", self.network) + + # Act + wiki = track.get_wiki_summary() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 1fc02a33c51211d3e03da1584469a4d9a0c68ad2 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 10:42:59 +0200 Subject: [PATCH 082/768] Jiggle the tests to increase coverage a bit --- pylast.py | 2 +- test_pylast.py | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pylast.py b/pylast.py index 149026f..e4f3a76 100644 --- a/pylast.py +++ b/pylast.py @@ -641,7 +641,7 @@ class LastFMNetwork(_Network): "'%s'" %self.username, "'%s'" %self.password_hash))) def __str__(self): - return "LastFM Network" + return "Last.fm Network" def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): """ diff --git a/test_pylast.py b/test_pylast.py index 4838f21..0f43cac 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -221,7 +221,7 @@ class TestPyLast(unittest.TestCase): # Arrange artist = "Test Artist" title = "Test Title" - track = pylast.Track(artist, title, self.network) + track = self.network.get_track(artist, title) lastfm_user = self.network.get_user(self.username) # Act @@ -390,7 +390,7 @@ class TestPyLast(unittest.TestCase): def test_country_is_hashable(self): # Arrange - country = pylast.Country("Italy", self.network) + country = self.network.get_country("Italy") # Act/Assert self.helper_is_thing_hashable(country) @@ -407,7 +407,7 @@ class TestPyLast(unittest.TestCase): def test_group_is_hashable(self): # Arrange - group = pylast.Group("Audioscrobbler Beta", self.network) + group = self.network.get_group("Audioscrobbler Beta") # Act/Assert self.helper_is_thing_hashable(group) @@ -680,7 +680,7 @@ class TestPyLast(unittest.TestCase): artist = pylast.Artist("Test Artist", self.network) # Act - bio = artist.get_bio_content() + bio = artist.get_bio_content(language = "en") # Assert self.assertIsNotNone(bio) @@ -692,7 +692,7 @@ class TestPyLast(unittest.TestCase): artist = pylast.Artist("Test Artist", self.network) # Act - bio = artist.get_bio_summary() + bio = artist.get_bio_summary(language = "en") # Assert self.assertIsNotNone(bio) @@ -747,6 +747,14 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(wiki), 1) + def test_lastfm_network_name(self): + # Act + name = str(self.network) + + # Assert + self.assertEqual(name, "Last.fm Network") + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 1d1eba5af7aaf392cfbb132363e4d9293be680bc Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 13:47:43 +0200 Subject: [PATCH 083/768] Add cacheable parameter for 16 functions that call _collect_nodes(). There's some 112 that call _request() and some may be made cacheable, but they can be done if someone asks for it. Closes #88. --- pylast.py | 62 ++++++++++++++--------------- test_pylast.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 31 deletions(-) diff --git a/pylast.py b/pylast.py index e4f3a76..1a365d5 100644 --- a/pylast.py +++ b/pylast.py @@ -1676,13 +1676,13 @@ class Artist(_BaseObject, _Taggable): ) return images - def get_shouts(self, limit=50): + def get_shouts(self, limit=50, cacheable=False): """ Returns a sequqence of Shout objects """ shouts = [] - for node in _collect_nodes(limit, self, "artist.getShouts", False): + for node in _collect_nodes(limit, self, "artist.getShouts", cacheable): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), @@ -1886,13 +1886,13 @@ class Event(_BaseObject): self._request('event.share', False, params) - def get_shouts(self, limit=50): + def get_shouts(self, limit=50, cacheable=False): """ Returns a sequqence of Shout objects """ shouts = [] - for node in _collect_nodes(limit, self, "event.getShouts", False): + for node in _collect_nodes(limit, self, "event.getShouts", cacheable): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), @@ -2084,10 +2084,10 @@ class Library(_BaseObject): self._request("library.addTrack", False, params) - def get_albums(self, artist=None, limit=50): + def get_albums(self, artist=None, limit=50, cacheable=True): """ Returns a sequence of Album objects - If no artist is specified, it will return all, sorted by playcount descendingly. + If no artist is specified, it will return all, sorted by decreasing play count. If limit==None it will return all (may take a while) """ @@ -2096,7 +2096,7 @@ class Library(_BaseObject): params["artist"] = artist seq = [] - for node in _collect_nodes(limit, self, "library.getAlbums", True, params): + for node in _collect_nodes(limit, self, "library.getAlbums", cacheable, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) @@ -2106,14 +2106,14 @@ class Library(_BaseObject): return seq - def get_artists(self, limit=50): + def get_artists(self, limit=50, cacheable=True): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ seq = [] - for node in _collect_nodes(limit, self, "library.getArtists", True): + for node in _collect_nodes(limit, self, "library.getArtists", cacheable): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) @@ -2123,7 +2123,7 @@ class Library(_BaseObject): return seq - def get_tracks(self, artist=None, album=None, limit=50): + def get_tracks(self, artist=None, album=None, limit=50, cacheable=True): """ Returns a sequence of Album objects If limit==None it will return all (may take a while) @@ -2136,7 +2136,7 @@ class Library(_BaseObject): params["album"] = album seq = [] - for node in _collect_nodes(limit, self, "library.getTracks", True, params): + for node in _collect_nodes(limit, self, "library.getTracks", cacheable, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) @@ -2721,13 +2721,13 @@ class Track(_BaseObject, _Taggable): return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} - def get_shouts(self, limit=50): + def get_shouts(self, limit=50, cacheable=False): """ Returns a sequqence of Shout objects """ shouts = [] - for node in _collect_nodes(limit, self, "track.getShouts", False): + for node in _collect_nodes(limit, self, "track.getShouts", cacheable): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), @@ -2854,13 +2854,13 @@ class Group(_BaseObject): return self.network._get_url(domain_name, "group") %{'name': name} - def get_members(self, limit=50): + def get_members(self, limit=50, cacheable=False): """ Returns a sequence of User objects if limit==None it will return all """ - nodes = _collect_nodes(limit, self, "group.getMembers", False) + nodes = _collect_nodes(limit, self, "group.getMembers", cacheable) users = [] @@ -2966,7 +2966,7 @@ class User(_BaseObject): return self._extract_events_from_doc(doc) - def get_artist_tracks(self, artist): + def get_artist_tracks(self, artist, cacheable=False): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" # Not implemented: "Can be limited to specific timeranges, defaults to all time." @@ -2974,7 +2974,7 @@ class User(_BaseObject): params['artist'] = artist seq = [] - for track in _collect_nodes(None, self, "user.getArtistTracks", False, params): + for track in _collect_nodes(None, self, "user.getArtistTracks", cacheable, params): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") @@ -2985,16 +2985,16 @@ class User(_BaseObject): return seq - def get_friends(self, limit = 50): + def get_friends(self, limit = 50, cacheable=False): """Returns a list of the user's friends. """ seq = [] - for node in _collect_nodes(limit, self, "user.getFriends", False): + for node in _collect_nodes(limit, self, "user.getFriends", cacheable): seq.append(User(_extract(node, "name"), self.network)) return seq - def get_loved_tracks(self, limit=50): + def get_loved_tracks(self, limit=50, cacheable=True): """Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. @@ -3011,7 +3011,7 @@ class User(_BaseObject): params['limit'] = limit seq = [] - for track in _collect_nodes(limit, self, "user.getLovedTracks", True, params): + for track in _collect_nodes(limit, self, "user.getLovedTracks", cacheable, params): title = _extract(track, "name") artist = _extract(track, "name", 1) @@ -3039,14 +3039,14 @@ class User(_BaseObject): return seq - def get_past_events(self, limit=50): + def get_past_events(self, limit=50, cacheable=False): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] - for n in _collect_nodes(limit, self, "user.getPastEvents", False): + for n in _collect_nodes(limit, self, "user.getPastEvents", cacheable): seq.append(Event(_extract(n, "id"), self.network)) return seq @@ -3086,7 +3086,7 @@ class User(_BaseObject): return Track(artist, title, self.network, self.name) - def get_recent_tracks(self, limit = 10): + def get_recent_tracks(self, limit=10, cacheable=True): """Returns this user's played track as a sequence of PlayedTrack objects in reverse order of their playtime, all the way back to the first track. @@ -3103,7 +3103,7 @@ class User(_BaseObject): params['limit'] = limit seq = [] - for track in _collect_nodes(limit, self, "user.getRecentTracks", True, params): + for track in _collect_nodes(limit, self, "user.getRecentTracks", cacheable, params): if track.hasAttribute('nowplaying'): continue #to prevent the now playing track from sneaking in here @@ -3412,13 +3412,13 @@ class User(_BaseObject): return Library(self, self.network) - def get_shouts(self, limit=50): + def get_shouts(self, limit=50, cacheable=False): """ Returns a sequqence of Shout objects """ shouts = [] - for node in _collect_nodes(limit, self, "user.getShouts", False): + for node in _collect_nodes(limit, self, "user.getShouts", cacheable): shouts.append(Shout( _extract(node, "body"), User(_extract(node, "author"), self.network), @@ -3452,26 +3452,26 @@ class AuthenticatedUser(User): self.name = _extract(doc, "name") return self.name - def get_recommended_events(self, limit=50): + def get_recommended_events(self, limit=50, cacheable=False): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedEvents", False): + for node in _collect_nodes(limit, self, "user.getRecommendedEvents", cacheable): seq.append(Event(_extract(node, "id"), self.network)) return seq - def get_recommended_artists(self, limit=50): + def get_recommended_artists(self, limit=50, cacheable=False): """ Returns a sequence of Event objects if limit==None it will return all """ seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedArtists", False): + for node in _collect_nodes(limit, self, "user.getRecommendedArtists", cacheable): seq.append(Artist(_extract(node, "name"), self.network)) return seq diff --git a/test_pylast.py b/test_pylast.py index 0f43cac..d2e03a2 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -755,6 +755,111 @@ class TestPyLast(unittest.TestCase): self.assertEqual(name, "Last.fm Network") + def test_artist_get_images_deprecated(self): + # Arrange + artist = self.network.get_artist("Test Artist") + + # Act/Assert + with self.assertRaisesRegexp(pylast.WSError, 'deprecated'): + artist.get_images() + + + def helper_validate_results(self, a, b, c): + # Assert + self.assertIsNotNone(a) + self.assertIsNotNone(b) + self.assertIsNotNone(c) + self.assertGreaterEqual(len(a), 0) + self.assertGreaterEqual(len(b), 0) + self.assertGreaterEqual(len(c), 0) + self.assertEqual(a, b) + self.assertEqual(b, c) + + + def helper_validate_cacheable(self, thing, function_name): + # Arrange + # get thing.function_name() + func = getattr(thing, function_name, None) + + # Act + result1 = func(limit = 1, cacheable = False) + result2 = func(limit = 1, cacheable = True) + result3 = func(limit = 1) + + # Assert + self.helper_validate_results(result1, result2, result3) + + + def test_cacheable_artist_get_shouts(self): + # Arrange + artist = self.network.get_artist("Test Artist") + + # Act/Assert + self.helper_validate_cacheable(artist, "get_shouts") + + + def test_cacheable_event_get_shouts(self): + # Arrange + user = self.network.get_user("RJ") + event = user.get_past_events(limit = 1)[0] + + # Act/Assert + self.helper_validate_cacheable(event, "get_shouts") + + + def test_cacheable_track_get_shouts(self): + # Arrange + track = self.network.get_top_tracks()[0] + + # Act/Assert + self.helper_validate_cacheable(track, "get_shouts") + + + def test_cacheable_group_get_members(self): + # Arrange + group = self.network.get_group("Audioscrobbler Beta") + + # Act/Assert + self.helper_validate_cacheable(group, "get_members") + + + def test_cacheable_library(self): + # Arrange + library = pylast.Library(self.username, self.network) + + # Act/Assert + self.helper_validate_cacheable(library, "get_albums") + self.helper_validate_cacheable(library, "get_artists") + self.helper_validate_cacheable(library, "get_tracks") + + + def test_cacheable_user_artist_tracks(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act + result1 = lastfm_user.get_artist_tracks(artist = "Test Artist", cacheable = False) + result2 = lastfm_user.get_artist_tracks(artist = "Test Artist", cacheable = True) + result3 = lastfm_user.get_artist_tracks(artist = "Test Artist") + + # Assert + self.helper_validate_results(result1, result2, result3) + + + def test_cacheable_user(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act/Assert + self.helper_validate_cacheable(lastfm_user, "get_friends") + self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") + self.helper_validate_cacheable(lastfm_user, "get_past_events") + self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") + self.helper_validate_cacheable(lastfm_user, "get_recommended_artists") + self.helper_validate_cacheable(lastfm_user, "get_recommended_events") + self.helper_validate_cacheable(lastfm_user, "get_shouts") + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 2d11699df0feec62097303165ce210df9ac26420 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 15:46:00 +0200 Subject: [PATCH 084/768] Implement firs Geo function, for #44 --- pylast.py | 63 ++++++++++++++++++++++++++++++++++++++++++-------- test_pylast.py | 40 ++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/pylast.py b/pylast.py index 1a365d5..91b9bb6 100644 --- a/pylast.py +++ b/pylast.py @@ -374,6 +374,49 @@ class _Network(object): return seq + def get_geo_events(self, long=None, lat=None, location=None, distance=None, tag=None, festivalsonly=None, limit=None, cacheable=True): + """ + Returns all events in a specific location by country or city name. + Parameters: + long (Optional) : Specifies a longitude value to retrieve events for (service returns nearby events by default) + lat (Optional) : Specifies a latitude value to retrieve events for (service returns nearby events by default) + location (Optional) : Specifies a location to retrieve events for (service returns nearby events by default) + distance (Optional) : Find events within a specified radius (in kilometres) + tag (Optional) : Specifies a tag to filter by. + festivalsonly[0|1] (Optional) : Whether only festivals should be returned, or all events. + limit (Optional) : The number of results to fetch per page. Defaults to 10. + (page number not implemented) + """ + + params = {} + + if long: params["long"] = long + if lat: params["lat"] = lat + if location: params["location"] = location + if limit: params["limit"] = limit + if distance: params["distance"] = distance + if tag: params["tag"] = tag + if festivalsonly: + params["festivalsonly"] = 1 + elif not festivalsonly: + params["festivalsonly"] = 0 + + doc = _Request(self, "geo.getEvents", params).execute(cacheable) + + return _extract_events_from_doc(doc, self) + + # TODO? + # geo.getMetroArtistChart + # geo.getMetroHypeArtistChart + # geo.getMetroHypeTrackChart + # geo.getMetroTrackChart + # geo.getMetroUniqueArtistChart + # geo.getMetroUniqueTrackChart + # geo.getMetroWeeklyChartlist + # geo.getMetros + # geo.getTopArtists + # geo.getTopTracks + def enable_proxy(self, host, port): """Enable a default web proxy""" @@ -1040,12 +1083,6 @@ class _BaseObject(object): return hash(self.network) + \ hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower()) - def _extract_events_from_doc(self, doc): - events = [] - for node in doc.getElementsByTagName("event"): - events.append(Event(_extract(node, "id"), self.network)) - return events - def _extract_cdata_from_request(self, method_name, tag_name, params): doc = self._request(method_name, True, params) @@ -1519,7 +1556,7 @@ class Artist(_BaseObject, _Taggable): doc = self._request('artist.getEvents', True) - return self._extract_events_from_doc(doc) + return _extract_events_from_doc(doc, self.network) def get_similar(self, limit = None): """Returns the similar artists on the network.""" @@ -2964,7 +3001,7 @@ class User(_BaseObject): doc = self._request('user.getEvents', True) - return self._extract_events_from_doc(doc) + return _extract_events_from_doc(doc, self.network) def get_artist_tracks(self, artist, cacheable=False): """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" @@ -3676,14 +3713,14 @@ class Venue(_BaseObject): doc = self._request("venue.getEvents", True) - return self._extract_events_from_doc(doc) + return _extract_events_from_doc(doc, self.network) def get_past_events(self): """Returns the past events held in this venue.""" doc = self._request("venue.getEvents", True) - return self._extract_events_from_doc(doc) + return _extract_events_from_doc(doc, self.network) def md5(text): """Returns the md5 hash of a string.""" @@ -3818,6 +3855,12 @@ def _extract_all(node, name, limit_count = None): return seq +def _extract_events_from_doc(doc, network): + events = [] + for node in doc.getElementsByTagName("event"): + events.append(Event(_extract(node, "id"), network)) + return events + def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a url.""" diff --git a/test_pylast.py b/test_pylast.py index d2e03a2..4527387 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -346,7 +346,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(len(tags), 0) - self.assertTrue(type(tags[0]) == pylast.TopItem) + self.assertEqual(type(tags[0]), pylast.TopItem) def test_album_tags_are_topitems(self): @@ -358,7 +358,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(len(tags), 0) - self.assertTrue(type(tags[0]) == pylast.TopItem) + self.assertEqual(type(tags[0]), pylast.TopItem) def helper_is_thing_hashable(self, thing): @@ -860,6 +860,42 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_shouts") + def test_geo_get_events_in_location(self): + # Arrange + # Act + events = self.network.get_geo_events(location = "London", tag = "blues", limit = 1) + + # Assert + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(type(event), pylast.Event) + self.assertEqual(event.get_venue().location['city'], "London") + + + def test_geo_get_events_in_latlong(self): + # Arrange + # Act + events = self.network.get_geo_events(lat = 40.67, long = -73.94, distance = 5, limit = 1) + + # Assert + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(type(event), pylast.Event) + self.assertEqual(event.get_venue().location['city'], "New York") + + + def test_geo_get_events_festival(self): + # Arrange + # Act + events = self.network.get_geo_events(location = "Reading", festivalsonly = True, limit = 1) + + # Assert + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(type(event), pylast.Event) + self.assertEqual(event.get_venue().location['city'], "Reading") + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 9ca4109f227a1c92d11e23be40a3129953c2ef0e Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 16:14:52 +0200 Subject: [PATCH 085/768] Implement geo.getTopTracks for #44 --- pylast.py | 29 ++++++++++++++++++++++++++++- test_pylast.py | 10 ++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 91b9bb6..4a7bd29 100644 --- a/pylast.py +++ b/pylast.py @@ -385,7 +385,6 @@ class _Network(object): tag (Optional) : Specifies a tag to filter by. festivalsonly[0|1] (Optional) : Whether only festivals should be returned, or all events. limit (Optional) : The number of results to fetch per page. Defaults to 10. - (page number not implemented) """ params = {} @@ -415,6 +414,34 @@ class _Network(object): # geo.getMetroWeeklyChartlist # geo.getMetros # geo.getTopArtists + + def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True): + """Get the most popular tracks on Last.fm last week by country + Parameters: + country (Required) : A country name, as defined by the ISO 3166-1 country names standard + location (Optional) : A metro name, to fetch the charts for (must be within the country specified) + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + params = {"country": country} + + if location: params["location"] = location + if limit: params["limit"] = limit + + doc = _Request(self, "geo.getTopTracks", params).execute(cacheable) + + tracks = doc.getElementsByTagName("track") + seq = [] + + for track in tracks: + title = _extract(track, "name") + artist = _extract(track, "name", 1) + listeners = _extract(track, "listeners") + + seq.append(TopItem(Track(artist, title, self), listeners)) + + return seq + + # TODO? # geo.getTopTracks def enable_proxy(self, host, port): diff --git a/test_pylast.py b/test_pylast.py index 4527387..2f3318e 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -896,6 +896,16 @@ class TestPyLast(unittest.TestCase): self.assertEqual(event.get_venue().location['city'], "Reading") + def test_geo_get_top_tracks(self): + # Arrange + # Act + tracks = self.network.get_geo_top_tracks(country = "United Kingdom", location = "Manchester", limit = 1) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertEqual(type(tracks[0]), pylast.TopItem) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 748f66ecc359543414bdcd5106494d0160147dd8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 16:29:41 +0200 Subject: [PATCH 086/768] Implement geo.getTopArtists for #44 --- pylast.py | 25 ++++++++++++++++++++++++- test_pylast.py | 12 ++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 4a7bd29..02ea3c1 100644 --- a/pylast.py +++ b/pylast.py @@ -415,8 +415,31 @@ class _Network(object): # geo.getMetros # geo.getTopArtists + def get_geo_top_artists(self, country, limit=None, cacheable=True): + """Get the most popular artists on Last.fm by country. + Parameters: + country (Required) : A country name, as defined by the ISO 3166-1 country names standard + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + params = {"country": country} + + if limit: params["limit"] = limit + + doc = _Request(self, "geo.getTopArtists", params).execute(cacheable) + + artists = doc.getElementsByTagName("artist") + seq = [] + + for artist in artists: + name = _extract(artist, "name") + listeners = _extract(artist, "listeners") + + seq.append(TopItem(Artist(name, self), listeners)) + + return seq + def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True): - """Get the most popular tracks on Last.fm last week by country + """Get the most popular tracks on Last.fm last week by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 country names standard location (Optional) : A metro name, to fetch the charts for (must be within the country specified) diff --git a/test_pylast.py b/test_pylast.py index 2f3318e..e8d84dd 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -896,6 +896,17 @@ class TestPyLast(unittest.TestCase): self.assertEqual(event.get_venue().location['city'], "Reading") + def test_geo_get_top_artists(self): + # Arrange + # Act + artists = self.network.get_geo_top_artists(country = "United Kingdom", limit = 1) + + # Assert + self.assertEqual(len(artists), 1) + self.assertEqual(type(artists[0]), pylast.TopItem) + self.assertEqual(type(artists[0].item), pylast.Artist) + + def test_geo_get_top_tracks(self): # Arrange # Act @@ -904,6 +915,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(tracks), 1) self.assertEqual(type(tracks[0]), pylast.TopItem) + self.assertEqual(type(tracks[0].item), pylast.Track) if __name__ == '__main__': From f7e5645ed6526a1cc32c86d4ac1cb5c2736ade70 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 17:11:50 +0200 Subject: [PATCH 087/768] Implement geo.getMetros for #44 --- pylast.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++---- test_pylast.py | 31 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/pylast.py b/pylast.py index 02ea3c1..e60ae42 100644 --- a/pylast.py +++ b/pylast.py @@ -226,6 +226,13 @@ class _Network(object): return Country(country_name, self) + def get_metro(self, metro_name, country_name): + """ + Returns a metro object + """ + + return Metro(metro_name, country_name, self) + def get_group(self, name): """ Returns a Group object @@ -412,8 +419,28 @@ class _Network(object): # geo.getMetroUniqueArtistChart # geo.getMetroUniqueTrackChart # geo.getMetroWeeklyChartlist - # geo.getMetros - # geo.getTopArtists + def get_metros(self, country=None, cacheable=True): + """Get a list of valid countries and metros for use in the other webservices. + Parameters: + country (Optional) : Optionally restrict the results to those Metros from a particular country, as defined by the ISO 3166-1 country names standard + """ + params = {} + + if country: params["country"] = country + + doc = _Request(self, "geo.getMetros", params).execute(cacheable) + + metros = doc.getElementsByTagName("metro") + seq = [] + + for metro in metros: + name = _extract(metro, "name") + country = _extract(metro, "country") + + seq.append(Metro(name, country, self)) + + return seq + def get_geo_top_artists(self, country, limit=None, cacheable=True): """Get the most popular artists on Last.fm by country. @@ -464,9 +491,6 @@ class _Network(object): return seq - # TODO? - # geo.getTopTracks - def enable_proxy(self, host, port): """Enable a default web proxy""" @@ -2089,6 +2113,48 @@ class Country(_BaseObject): return self.network._get_url(domain_name, "country") %{'country_name': country_name} +class Metro(_BaseObject): + """A metro at Last.fm.""" + + name = None + country = None + + __hash__ = _BaseObject.__hash__ + + def __init__(self, name, country, network): + _BaseObject.__init__(self, network) + + self.name = name + self.country = country + + def __repr__(self): + return "pylast.Metro(%s, %s, %s)" %(repr(self.name), repr(self.country), repr(self.network)) + + @_string_output + def __str__(self): + return self.get_name() + ", " + self.get_country() + + def __eq__(self, other): + return self.get_name().lower() == other.get_name().lower() and \ + self.get_country().lower() == other.get_country().lower() + + def __ne__(self, other): + return self.get_name() != other.get_name() or \ + self.get_country().lower() != other.get_country().lower() + + def _get_params(self): + return {'name': self.get_name(), 'country': self.get_country()} + + def get_name(self): + """Returns the metro name.""" + + return self.name + + def get_country(self): + """Returns the metro country.""" + + return self.country + class Library(_BaseObject): """A user's Last.fm library.""" diff --git a/test_pylast.py b/test_pylast.py index e8d84dd..e1cdc9b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -396,6 +396,14 @@ class TestPyLast(unittest.TestCase): self.helper_is_thing_hashable(country) + def test_country_is_hashable(self): + # Arrange + metro = self.network.get_metro("Helsinki", "Finland") + + # Act/Assert + self.helper_is_thing_hashable(metro) + + def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") @@ -896,6 +904,16 @@ class TestPyLast(unittest.TestCase): self.assertEqual(event.get_venue().location['city'], "Reading") + def test_geo_get_metros(self): + # Arrange + # Act + metros = self.network.get_metros(country = "Poland") + + # Assert + self.assertGreaterEqual(len(metros), 1) + self.assertEqual(type(metros[0]), pylast.Metro) + + def test_geo_get_top_artists(self): # Arrange # Act @@ -918,6 +936,19 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(tracks[0].item), pylast.Track) + def test_metro_class(self): + # Arrange + # Act + metro = self.network.get_metro("Bergen", "Norway") + + # Assert + self.assertEqual(metro.get_name(), "Bergen") + self.assertEqual(metro.get_country(), "Norway") + self.assertEqual(str(metro), "Bergen, Norway") + self.assertEqual(metro, pylast.Metro("Bergen", "Norway", self.network)) + self.assertNotEqual(metro, pylast.Metro("Wellington", "New Zealand", self.network)) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From a254a9fd0ccafc4d64e17cc9439565bea115b832 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 17:49:56 +0200 Subject: [PATCH 088/768] Implement geo.getMetroWeeklyChartlist and geo.getMetroArtistChart for #44 --- pylast.py | 55 +++++++++++++++++++++++++++++++++++++++++--------- test_pylast.py | 22 ++++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/pylast.py b/pylast.py index e60ae42..ad674d7 100644 --- a/pylast.py +++ b/pylast.py @@ -411,14 +411,17 @@ class _Network(object): return _extract_events_from_doc(doc, self) - # TODO? - # geo.getMetroArtistChart - # geo.getMetroHypeArtistChart - # geo.getMetroHypeTrackChart - # geo.getMetroTrackChart - # geo.getMetroUniqueArtistChart - # geo.getMetroUniqueTrackChart - # geo.getMetroWeeklyChartlist + def get_metro_weekly_chart_dates(self, cacheable=True): + """Returns a list of From and To tuples for the available metro charts.""" + + doc = _Request(self, "geo.getMetroWeeklyChartlist").execute(cacheable) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + + return seq + def get_metros(self, country=None, cacheable=True): """Get a list of valid countries and metros for use in the other webservices. Parameters: @@ -2143,7 +2146,7 @@ class Metro(_BaseObject): self.get_country().lower() != other.get_country().lower() def _get_params(self): - return {'name': self.get_name(), 'country': self.get_country()} + return {'metro': self.get_name(), 'country': self.get_country()} def get_name(self): """Returns the metro name.""" @@ -2155,6 +2158,40 @@ class Metro(_BaseObject): return self.country + def get_artist_chart(self, limit=None, cacheable=True): + """Get a chart of artists for a metro. + Parameters: + TODO start (Optional) : Beginning timestamp of the weekly range requested (c.f. geo.getWeeklyChartlist) + TODO end (Optional) : Ending timestamp of the weekly range requested (c.f. geo.getWeeklyChartlist) + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + + params = self._get_params() + if limit: params["limit"] = limit + # if from_date and to_date: + # params["from"] = from_date + # params["to"] = to_date + + doc = self._request("geo.getMetroArtistChart", cacheable, params) + + seq = [] + for node in doc.getElementsByTagName("artist"): + item = Artist(_extract(node, "name"), self.network) + weight = _number(_extract(node, "listeners")) + seq.append(TopItem(item, weight)) + + return seq + + + # TODO? + # geo.getMetroHypeArtistChart + # geo.getMetroHypeTrackChart + # geo.getMetroTrackChart + # geo.getMetroUniqueArtistChart + # geo.getMetroUniqueTrackChart + # geo.getMetroWeeklyChartlist + + class Library(_BaseObject): """A user's Last.fm library.""" diff --git a/test_pylast.py b/test_pylast.py index e1cdc9b..9859039 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -904,6 +904,28 @@ class TestPyLast(unittest.TestCase): self.assertEqual(event.get_venue().location['city'], "Reading") + def test_get_metro_artist_chart(self): + # Arrange + metro = self.network.get_metro("Salamanca", "Spain") + + # Act + chart = metro.get_artist_chart() + + # Assert + self.assertEqual(type(chart[0]), pylast.TopItem) + self.assertEqual(type(chart[0].item), pylast.Artist) + + def test_get_metro_weekly_chart_dates(self): + # Arrange + # Act + dates = self.network.get_metro_weekly_chart_dates() + + # Assert + self.assertGreaterEqual(len(dates), 1) + self.assertEqual(type(dates[0]), tuple) + (start, end) = dates[0] + self.assertLess(start, end) + def test_geo_get_metros(self): # Arrange # Act From c587600e1617f7fe81fb95f6d35e4500bfaa5058 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 17:58:50 +0200 Subject: [PATCH 089/768] Add from and to dates for artist charts, for #44 --- pylast.py | 12 ++++++------ test_pylast.py | 26 +++++++++++++++----------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/pylast.py b/pylast.py index ad674d7..814e26e 100644 --- a/pylast.py +++ b/pylast.py @@ -2158,19 +2158,19 @@ class Metro(_BaseObject): return self.country - def get_artist_chart(self, limit=None, cacheable=True): + def get_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): """Get a chart of artists for a metro. Parameters: - TODO start (Optional) : Beginning timestamp of the weekly range requested (c.f. geo.getWeeklyChartlist) - TODO end (Optional) : Ending timestamp of the weekly range requested (c.f. geo.getWeeklyChartlist) + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested limit (Optional) : The number of results to fetch per page. Defaults to 50. """ params = self._get_params() if limit: params["limit"] = limit - # if from_date and to_date: - # params["from"] = from_date - # params["to"] = to_date + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date doc = self._request("geo.getMetroArtistChart", cacheable, params) diff --git a/test_pylast.py b/test_pylast.py index 9859039..7643b52 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -904,17 +904,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(event.get_venue().location['city'], "Reading") - def test_get_metro_artist_chart(self): - # Arrange - metro = self.network.get_metro("Salamanca", "Spain") - - # Act - chart = metro.get_artist_chart() - - # Assert - self.assertEqual(type(chart[0]), pylast.TopItem) - self.assertEqual(type(chart[0].item), pylast.Artist) - def test_get_metro_weekly_chart_dates(self): # Arrange # Act @@ -926,6 +915,21 @@ class TestPyLast(unittest.TestCase): (start, end) = dates[0] self.assertLess(start, end) + + def test_get_metro_artist_chart(self): + # Arrange + metro = self.network.get_metro("Salamanca", "Spain") + dates = self.network.get_metro_weekly_chart_dates() + (from_date, to_date) = dates[0] + + # Act + chart = metro.get_artist_chart(from_date = from_date, to_date = to_date, limit = 1) + + # Assert + self.assertEqual(len(chart), 1) + self.assertEqual(type(chart[0]), pylast.TopItem) + self.assertEqual(type(chart[0].item), pylast.Artist) + def test_geo_get_metros(self): # Arrange # Act From 2938255e2fea872f1a9ffff98296944a4315d096 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 18:12:56 +0200 Subject: [PATCH 090/768] Implement geo.getMetroHypeArtistChart and refactor --- pylast.py | 28 ++++++++++++++++++++-------- test_pylast.py | 18 ++++++++++++++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/pylast.py b/pylast.py index 814e26e..542e0ab 100644 --- a/pylast.py +++ b/pylast.py @@ -2158,14 +2158,8 @@ class Metro(_BaseObject): return self.country - def get_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): - """Get a chart of artists for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. - """ - + def _get_chart(self, method, limit=None, from_date=None, to_date=None, cacheable=True): + """Internal helper for getting geo charts.""" params = self._get_params() if limit: params["limit"] = limit if from_date and to_date: @@ -2182,6 +2176,24 @@ class Metro(_BaseObject): return seq + def get_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of artists for a metro. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroArtistChart", limit, from_date, to_date, cacheable) + + def get_hype_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of hyped (up and coming) artists for a metro. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroHypeArtistChart", limit, from_date, to_date, cacheable) + # TODO? # geo.getMetroHypeArtistChart diff --git a/test_pylast.py b/test_pylast.py index 7643b52..c453488 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -916,20 +916,34 @@ class TestPyLast(unittest.TestCase): self.assertLess(start, end) - def test_get_metro_artist_chart(self): + def helper_get_metro_and_dates(self, function_name): # Arrange metro = self.network.get_metro("Salamanca", "Spain") dates = self.network.get_metro_weekly_chart_dates() (from_date, to_date) = dates[0] + # get metro.function_name() + func = getattr(metro, function_name, None) + # Act - chart = metro.get_artist_chart(from_date = from_date, to_date = to_date, limit = 1) + chart = func(from_date = from_date, to_date = to_date, limit = 1) # Assert self.assertEqual(len(chart), 1) self.assertEqual(type(chart[0]), pylast.TopItem) self.assertEqual(type(chart[0].item), pylast.Artist) + + def test_get_metro_artist_chart(self): + # Arrange/Act/Assert + self.helper_get_metro_and_dates("get_artist_chart") + + + def test_get_metro_hype_artist_chart(self): + # Arrange/Act/Assert + self.helper_get_metro_and_dates("get_hype_artist_chart") + + def test_geo_get_metros(self): # Arrange # Act From a97c5840590a419590cac8218e29c02647b35cb6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 18:17:37 +0200 Subject: [PATCH 091/768] Implement geo.getMetroUniqueArtistChart for #44 --- pylast.py | 12 +++++++++--- test_pylast.py | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pylast.py b/pylast.py index 542e0ab..134b49d 100644 --- a/pylast.py +++ b/pylast.py @@ -2194,14 +2194,20 @@ class Metro(_BaseObject): """ return self._get_chart("geo.getMetroHypeArtistChart", limit, from_date, to_date, cacheable) + def get_unique_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of the artists which make that metro unique. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroUniqueArtistChart", limit, from_date, to_date, cacheable) + # TODO? - # geo.getMetroHypeArtistChart # geo.getMetroHypeTrackChart # geo.getMetroTrackChart - # geo.getMetroUniqueArtistChart # geo.getMetroUniqueTrackChart - # geo.getMetroWeeklyChartlist class Library(_BaseObject): diff --git a/test_pylast.py b/test_pylast.py index c453488..67b6d49 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -944,6 +944,11 @@ class TestPyLast(unittest.TestCase): self.helper_get_metro_and_dates("get_hype_artist_chart") + def test_get_metro_unique_artist_chart(self): + # Arrange/Act/Assert + self.helper_get_metro_and_dates("get_unique_artist_chart") + + def test_geo_get_metros(self): # Arrange # Act From daa590b11d8ad0640d18db66106aeba3d14d29e8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 18:56:28 +0200 Subject: [PATCH 092/768] Implement geo.getMetroHypeTrackChart, geo.getMetroTrackChart and geo.getMetroUniqueTrackChart. Closes #44. --- pylast.py | 55 +++++++++++++++++++++++++++++++++++++------------- test_pylast.py | 27 +++++++++++++++++++------ 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/pylast.py b/pylast.py index 134b49d..93abf58 100644 --- a/pylast.py +++ b/pylast.py @@ -2158,7 +2158,7 @@ class Metro(_BaseObject): return self.country - def _get_chart(self, method, limit=None, from_date=None, to_date=None, cacheable=True): + def _get_chart(self, method, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): """Internal helper for getting geo charts.""" params = self._get_params() if limit: params["limit"] = limit @@ -2166,49 +2166,76 @@ class Metro(_BaseObject): params["from"] = from_date params["to"] = to_date - doc = self._request("geo.getMetroArtistChart", cacheable, params) + doc = self._request(method, cacheable, params) seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) + for node in doc.getElementsByTagName(tag): + if tag == "artist": + item = Artist(_extract(node, "name"), self.network) + elif tag == "track": + title = _extract(node, "name") + artist = _extract_element_tree(node).get('artist')['name'] + item = Track(artist, title, self.network) + else: + return None weight = _number(_extract(node, "listeners")) seq.append(TopItem(item, weight)) return seq - def get_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + def get_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): """Get a chart of artists for a metro. Parameters: from_date (Optional) : Beginning timestamp of the weekly range requested to_date (Optional) : Ending timestamp of the weekly range requested limit (Optional) : The number of results to fetch per page. Defaults to 50. """ - return self._get_chart("geo.getMetroArtistChart", limit, from_date, to_date, cacheable) + return self._get_chart("geo.getMetroArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) - def get_hype_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + def get_hype_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): """Get a chart of hyped (up and coming) artists for a metro. Parameters: from_date (Optional) : Beginning timestamp of the weekly range requested to_date (Optional) : Ending timestamp of the weekly range requested limit (Optional) : The number of results to fetch per page. Defaults to 50. """ - return self._get_chart("geo.getMetroHypeArtistChart", limit, from_date, to_date, cacheable) + return self._get_chart("geo.getMetroHypeArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) - def get_unique_artist_chart(self, limit=None, from_date=None, to_date=None, cacheable=True): + def get_unique_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): """Get a chart of the artists which make that metro unique. Parameters: from_date (Optional) : Beginning timestamp of the weekly range requested to_date (Optional) : Ending timestamp of the weekly range requested limit (Optional) : The number of results to fetch per page. Defaults to 50. """ - return self._get_chart("geo.getMetroUniqueArtistChart", limit, from_date, to_date, cacheable) + return self._get_chart("geo.getMetroUniqueArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + def get_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of tracks for a metro. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) - # TODO? - # geo.getMetroHypeTrackChart - # geo.getMetroTrackChart - # geo.getMetroUniqueTrackChart + def get_hype_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of tracks for a metro. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroHypeTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + def get_unique_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + """Get a chart of tracks for a metro. + Parameters: + from_date (Optional) : Beginning timestamp of the weekly range requested + to_date (Optional) : Ending timestamp of the weekly range requested + limit (Optional) : The number of results to fetch per page. Defaults to 50. + """ + return self._get_chart("geo.getMetroUniqueTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) class Library(_BaseObject): """A user's Last.fm library.""" diff --git a/test_pylast.py b/test_pylast.py index 67b6d49..cf62498 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -916,9 +916,9 @@ class TestPyLast(unittest.TestCase): self.assertLess(start, end) - def helper_get_metro_and_dates(self, function_name): + def helper_geo_chart(self, function_name, expected_type = pylast.Artist): # Arrange - metro = self.network.get_metro("Salamanca", "Spain") + metro = self.network.get_metro("Madrid", "Spain") dates = self.network.get_metro_weekly_chart_dates() (from_date, to_date) = dates[0] @@ -931,22 +931,37 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(chart), 1) self.assertEqual(type(chart[0]), pylast.TopItem) - self.assertEqual(type(chart[0].item), pylast.Artist) + self.assertEqual(type(chart[0].item), expected_type) def test_get_metro_artist_chart(self): # Arrange/Act/Assert - self.helper_get_metro_and_dates("get_artist_chart") + self.helper_geo_chart("get_artist_chart") def test_get_metro_hype_artist_chart(self): # Arrange/Act/Assert - self.helper_get_metro_and_dates("get_hype_artist_chart") + self.helper_geo_chart("get_hype_artist_chart") def test_get_metro_unique_artist_chart(self): # Arrange/Act/Assert - self.helper_get_metro_and_dates("get_unique_artist_chart") + self.helper_geo_chart("get_unique_artist_chart") + + + def test_get_metro_track_chart(self): + # Arrange/Act/Assert + self.helper_geo_chart("get_track_chart", expected_type = pylast.Track) + + + def test_get_metro_hype_track_chart(self): + # Arrange/Act/Assert + self.helper_geo_chart("get_hype_track_chart", expected_type = pylast.Track) + + + def test_get_metro_unique_track_chart(self): + # Arrange/Act/Assert + self.helper_geo_chart("get_unique_track_chart", expected_type = pylast.Track) def test_geo_get_metros(self): From e02893bbaa2e5d6757cdd145736c914031a689d6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 4 Mar 2014 23:04:50 +0200 Subject: [PATCH 093/768] Update README.md --- README.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 051b4fe..9e47136 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,27 @@ pyLast [![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) [![Coverage Status](https://coveralls.io/repos/hugovk/pylast/badge.png?branch=master)](https://coveralls.io/r/hugovk/pylast?branch=master) -A Python interface to Last.fm (and other api-compatible websites such as Libre.fm). +A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). -Try using the pydoc utility for help on usage or see test_pylast.py for examples. +Try using the pydoc utility for help on usage or see [test_pylast.py](test_pylast.py) for examples. Original code can be found at http://code.google.com/p/pylast/ but hasn't been updated since 2011. +Installation +------------ + +The old 0.5 version from 2011 is in PyPI so if you have it installed remove it first: + + pip uninstall pylast + + +You can install this version with pip like this: + + pip install -e git://github.com/hugovk/pylast.git#egg=pylast + +Or just copy [pylast.py](pylast.py) to somewhere your Python can see it. No other dependencies are needed. + + Features -------- @@ -56,12 +71,14 @@ track.add_tags(("awesome", "favorite")) # about anything and see examples of how it works ``` +More examples in hugovk/lastfm-tools and [test_pylast.py](test_pylast.py). + Testing ------- -test_pylast.py contains integration tests with Last.fm, and plenty of code examples. +[test_pylast.py](test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. -You need a test account at Last.fm that can be cluttered with test data, and an API key and secret. Either copy example_test_pylast.yaml to test_pylast.yaml and fill out the credentials, or set them as environment variables like: +You need a test account at Last.fm that will be cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ``` export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE @@ -78,7 +95,6 @@ pip install pyyaml To run with coverage: ``` -pip install pip install coverage coverage run --source=pylast ./test_pylast.py coverage report # for command-line report From 3d169178ae47b097b4595c7817fa99241da45f63 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 00:41:26 +0200 Subject: [PATCH 094/768] Implement artist/album/track.getPlaylinks, closes #74 --- .gitignore | 1 + pylast.py | 33 +++++++++++++++++++++++++++++++++ test_pylast.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/.gitignore b/.gitignore index e7288ae..72eb345 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ docs/_build/ # Test files test_pylast.yaml lastfm.txt.pkl +secrets.sh diff --git a/pylast.py b/pylast.py index 93abf58..57ab220 100644 --- a/pylast.py +++ b/pylast.py @@ -699,6 +699,39 @@ class _Network(object): if remaining_tracks: self.scrobble_many(remaining_tracks) + def get_play_links(self, type, things, cacheable=True): + method = type + ".getPlaylinks" + params = {} + + for i, thing in enumerate(things): + if type == "artist": + params['artist[' + str(i) + ']'] = thing + elif type == "album": + params['artist[' + str(i) + ']'] = thing.artist + params['album[' + str(i) + ']'] = thing.title + elif type == "track": + params['artist[' + str(i) + ']'] = thing.artist + params['track[' + str(i) + ']'] = thing.title + + doc = _Request(self, method, params).execute(cacheable) + + seq = [] + + for node in doc.getElementsByTagName("externalids"): + spotify = _extract(node, "spotify") + seq.append(spotify) + + return seq + + def get_artist_play_links(self, artists, cacheable=True): + return self.get_play_links("artist", artists) + + def get_album_play_links(self, albums, cacheable=True): + return self.get_play_links("album", albums) + + def get_track_play_links(self, tracks, cacheable=True): + return self.get_play_links("track", tracks) + class LastFMNetwork(_Network): """A Last.fm network object diff --git a/test_pylast.py b/test_pylast.py index cf62498..d027f59 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -972,6 +972,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(metros), 1) self.assertEqual(type(metros[0]), pylast.Metro) + self.assertEqual(metros[0].get_country(), "Poland") def test_geo_get_top_artists(self): @@ -1009,6 +1010,54 @@ class TestPyLast(unittest.TestCase): self.assertNotEqual(metro, pylast.Metro("Wellington", "New Zealand", self.network)) + def test_get_album_play_links(self): + # Arrange + album1 = self.network.get_album(artist = "Portishead", title = "Dummy") + album2 = self.network.get_album(artist = "Radiohead", title = "OK Computer") + albums = [album1, album2] + + # Act + links = self.network.get_album_play_links(albums) + + # Assert + self.assertEqual(type(links), list) + self.assertEqual(len(links), 2) + # How permanent are Spotify IDs? If they change, make tests more robust + self.assertEqual(links[0], "spotify:album:3gxOtUSRzweDWBKlpj7cG6") + self.assertEqual(links[1], "spotify:album:2fGCAYUMssLKiUAoNdxGLx") + + + def test_get_artist_play_links(self): + # Arrange + artists = ["Portishead", "Radiohead"] + # Act + links = self.network.get_artist_play_links(artists) + + # Assert + self.assertEqual(type(links), list) + self.assertEqual(len(links), 2) + # How permanent are Spotify IDs? If they change, make tests more robust + self.assertEqual(links[0], "spotify:artist:6liAMWkVf5LH7YR9yfFy1Y") + self.assertEqual(links[1], "spotify:artist:4Z8W4fKeB5YxbusRsdQVPb") + + + def test_get_track_play_links(self): + # Arrange + track1 = self.network.get_track(artist = "Portishead", title = "Mysterons") + track2 = self.network.get_track(artist = "Radiohead", title = "Creep") + tracks = [track1, track2] + + # Act + links = self.network.get_track_play_links(tracks) + + # Assert + self.assertEqual(type(links), list) + self.assertEqual(len(links), 2) + # How permanent are Spotify IDs? If they change, make tests more robust + self.assertEqual(links[0], "spotify:track:2bt04YlMnqiwA3T6O9UqBO") + self.assertEqual(links[1], "spotify:track:0KYHSg38GsU1naJ5jh1llP") + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 2a48ccf369af41ad2b8bc6d25a2817675aa94253 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 00:51:20 +0200 Subject: [PATCH 095/768] The CI got different Spotify IDs to my computer. Generalise the tests. For #74. --- test_pylast.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index d027f59..19253c0 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1022,9 +1022,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(type(links), list) self.assertEqual(len(links), 2) - # How permanent are Spotify IDs? If they change, make tests more robust - self.assertEqual(links[0], "spotify:album:3gxOtUSRzweDWBKlpj7cG6") - self.assertEqual(links[1], "spotify:album:2fGCAYUMssLKiUAoNdxGLx") + self.assertIn("spotify:album:", links[0]) + self.assertIn("spotify:album:", links[1]) def test_get_artist_play_links(self): @@ -1036,9 +1035,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(type(links), list) self.assertEqual(len(links), 2) - # How permanent are Spotify IDs? If they change, make tests more robust - self.assertEqual(links[0], "spotify:artist:6liAMWkVf5LH7YR9yfFy1Y") - self.assertEqual(links[1], "spotify:artist:4Z8W4fKeB5YxbusRsdQVPb") + self.assertIn("spotify:artist:", links[0]) + self.assertIn("spotify:artist:", links[1]) def test_get_track_play_links(self): @@ -1053,9 +1051,9 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(type(links), list) self.assertEqual(len(links), 2) - # How permanent are Spotify IDs? If they change, make tests more robust - self.assertEqual(links[0], "spotify:track:2bt04YlMnqiwA3T6O9UqBO") - self.assertEqual(links[1], "spotify:track:0KYHSg38GsU1naJ5jh1llP") + # How permanent are spotify IDs? If they change, make tests more robust + self.assertIn("spotify:track:", links[0]) + self.assertIn("spotify:track:", links[1]) if __name__ == '__main__': From f8f9226e9415397dab621a1269c3ead66375f760 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 01:01:27 +0200 Subject: [PATCH 096/768] Remove a couple of resource warnings from not closing files --- test_pylast.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 19253c0..286bf7c 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -676,8 +676,10 @@ class TestPyLast(unittest.TestCase): lastfm_user = self.network.get_user(self.username) # Act - pickle.dump(lastfm_user, open("lastfm.txt.pkl", "wb")) - loaded_user = pickle.load(open("lastfm.txt.pkl", "rb")) + with open("lastfm.txt.pkl", "wb") as f: + pickle.dump(lastfm_user, f) + with open("lastfm.txt.pkl", "rb") as f: + loaded_user = pickle.load(f) # Assert self.assertEqual(lastfm_user, loaded_user) From 4698993421a63162e9122c0ea997419381da8907 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 01:29:58 +0200 Subject: [PATCH 097/768] Jiggle the tests to increase coverage a bit --- test_pylast.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 286bf7c..d844856 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -382,7 +382,9 @@ class TestPyLast(unittest.TestCase): def test_artist_is_hashable(self): # Arrange - artist = self.network.get_artist("Test Artist") + test_artist = self.network.get_artist("Test Artist") + artist = test_artist.get_similar(limit=1)[0].item + self.assertEqual(type(artist), pylast.Artist) # Act/Assert self.helper_is_thing_hashable(artist) @@ -447,8 +449,9 @@ class TestPyLast(unittest.TestCase): def test_track_is_hashable(self): # Arrange - lastfm_user = self.network.get_user(self.username) - track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing + artist = self.network.get_artist("Test Artist") + track = artist.get_top_tracks()[0].item + self.assertEqual(type(track), pylast.Track) # Act/Assert self.helper_is_thing_hashable(track) @@ -456,10 +459,12 @@ class TestPyLast(unittest.TestCase): def test_user_is_hashable(self): # Arrange - lastfm_user = self.network.get_user(self.username) + artist = self.network.get_artist("Test Artist") + user = artist.get_top_fans(limit=1)[0].item + self.assertEqual(type(user), pylast.User) # Act/Assert - self.helper_is_thing_hashable(lastfm_user) + self.helper_is_thing_hashable(user) def test_venue_is_hashable(self): From 779af598db860db512677d91eafdfc74bffde2ca Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 10:29:16 +0200 Subject: [PATCH 098/768] Refactor to include limit parameter to reduce bandwidth\n\nRefactor calls to chart.getTopArtists, chart.getTopTracks, tag.getTopTags and user.getTopTags to include the limit parameter (where available) to reduce the size of data sent by Last.fm.\n\nFor example, getting limit=1 can reduce receiving 101 items to 1, making the test take 0.5s rather than 1.2s.\n\nAlso return a list of TopItems rather than just items, and add cacheable parameter. --- pylast.py | 61 +++++++++++++++++++++++++++----------------------- test_pylast.py | 52 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/pylast.py b/pylast.py index 57ab220..f5c73da 100644 --- a/pylast.py +++ b/pylast.py @@ -334,51 +334,55 @@ class _Network(object): return Playlist(user, e_id, self) - def get_top_artists(self, limit=None): - """Returns a sequence of the most played artists.""" + def get_top_artists(self, limit=None, cacheable=True): + """Returns the most played artists as a sequence of TopItem objects.""" + + params = {} + if limit: params["limit"] = limit + + doc = _Request(self, "chart.getTopArtists", params).execute(cacheable) - doc = _Request(self, "chart.getTopArtists").execute(True) seq = [] for node in doc.getElementsByTagName("artist"): - title = _extract(node, "name") - artist = Artist(title, self) - seq.append(artist) - - if limit: - seq = seq[:limit] + artist = Artist(_extract(node, "name"), self) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(artist, weight)) return seq - def get_top_tracks(self, limit=None): - """Returns a sequence of the most played tracks.""" + def get_top_tracks(self, limit=None, cacheable=True): + """Returns the most played tracks as a sequence of TopItem objects.""" + + params = {} + if limit: params["limit"] = limit + + doc = _Request(self, "chart.getTopTracks", params).execute(cacheable) - doc = _Request(self, "chart.getTopTracks").execute(True) seq = [] for node in doc.getElementsByTagName("track"): title = _extract(node, "name") artist = _extract(node, "name", 1) track = Track(artist, title, self) - seq.append(track) - - if limit: - seq = seq[:limit] + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(track, weight)) return seq - def get_top_tags(self, limit=None): - """Returns a sequence of the most used tags as a sequence of TopItem objects.""" + def get_top_tags(self, limit=None, cacheable=True): + """Returns the most used tags as a sequence of TopItem objects.""" + + # Last.fm has no "limit" parameter for tag.getTopTags + # so we need to get all (250) and then limit locally + doc = _Request(self, "tag.getTopTags").execute(cacheable) - doc = _Request(self, "tag.getTopTags").execute(True) seq = [] for node in doc.getElementsByTagName("tag"): + if len(seq) >= limit: + break tag = Tag(_extract(node, "name"), self) weight = _number(_extract(node, "count")) - seq.append(TopItem(tag, weight)) - if limit: - seq = seq[:limit] - return seq def get_geo_events(self, long=None, lat=None, location=None, distance=None, tag=None, festivalsonly=None, limit=None, cacheable=True): @@ -3510,20 +3514,21 @@ class User(_BaseObject): return seq - def get_top_tags(self, limit=None): + def get_top_tags(self, limit=None, cacheable=True): """Returns a sequence of the top tags used by this user with their counts as TopItem objects. * limit: The limit of how many tags to return. + * cacheable: Whether to cache results. """ - doc = self._request("user.getTopTags", True) + params = self._get_params() + if limit: params["limit"] = limit + + doc = self._request("user.getTopTags", cacheable, params) seq = [] for node in doc.getElementsByTagName("tag"): seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) - if limit: - seq = seq[:limit] - return seq def get_top_tracks(self, period = PERIOD_OVERALL): diff --git a/test_pylast.py b/test_pylast.py index d844856..c7bb82c 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -100,7 +100,7 @@ class TestPyLast(unittest.TestCase): # Arrange library = pylast.Library(user = self.username, network = self.network) # Pick an artist with plenty of albums - artist = self.network.get_top_artists()[0] + artist = self.network.get_top_artists(limit = 1)[0].item albums = artist.get_top_albums() # Pick a random one to avoid problems running concurrent tests album = choice(albums)[0] @@ -140,7 +140,7 @@ class TestPyLast(unittest.TestCase): # Get plenty of artists artists = self.network.get_top_artists() # Pick a random one to avoid problems running concurrent tests - my_artist = choice(artists) + my_artist = choice(artists).item library = pylast.Library(user = self.username, network = self.network) library.add_artist(my_artist) @@ -824,7 +824,7 @@ class TestPyLast(unittest.TestCase): def test_cacheable_track_get_shouts(self): # Arrange - track = self.network.get_top_tracks()[0] + track = self.network.get_top_tracks()[0].item # Act/Assert self.helper_validate_cacheable(track, "get_shouts") @@ -1058,11 +1058,55 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(type(links), list) self.assertEqual(len(links), 2) - # How permanent are spotify IDs? If they change, make tests more robust self.assertIn("spotify:track:", links[0]) self.assertIn("spotify:track:", links[1]) + def helper_only_one_thing_in_top_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 1) + self.assertEqual(type(things), list) + self.assertEqual(type(things[0]), pylast.TopItem) + self.assertEqual(type(things[0].item), expected_type) + + def test_user_get_top_tags_with_limit(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + tags = user.get_top_tags(limit = 1) + + # Assert + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + + def test_network_get_top_artists_with_limit(self): + # Arrange + # Act + artists = self.network.get_top_artists(limit = 1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + + def test_network_get_top_tags_with_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags(limit = 1) + + # Assert + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + + def test_network_get_top_tracks_with_limit(self): + # Arrange + # Act + tracks = self.network.get_top_tracks(limit = 1) + + # Assert + self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 1d532c0363afad17b6667a1b6df361e46d71f2e6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 5 Mar 2014 18:11:57 +0200 Subject: [PATCH 099/768] Run pyflakes and pep8 and update --- .build | 2 +- pylast.py | 1372 +++++++++++++++++++++++++++++++----------------- test_pylast.py | 288 ++++------ 3 files changed, 999 insertions(+), 663 deletions(-) diff --git a/.build b/.build index 60d3b2f..b6a7d89 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -15 +16 diff --git a/pylast.py b/pylast.py index f5c73da..595f617 100644 --- a/pylast.py +++ b/pylast.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # -# pylast - A Python interface to Last.fm (and other API compatible social networks) +# pylast - +# A Python interface to Last.fm (and other API compatible social networks) # # Copyright 2008-2010 Amr Hassan # @@ -35,6 +36,7 @@ import collections import warnings import re + def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -115,41 +117,51 @@ SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" # From http://boodebr.org/main/python/all-about-python-and-unicode#UNI_XML -RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \ - u'|' + \ - u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \ - (unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), - unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), - unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff)) +RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + + u'|' + + u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' + % + (unichr(0xd800), unichr(0xdbff), unichr(0xdc00), + unichr(0xdfff), unichr(0xd800), unichr(0xdbff), + unichr(0xdc00), unichr(0xdfff), unichr(0xd800), + unichr(0xdbff), unichr(0xdc00), unichr(0xdfff))) XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) + class _Network(object): """ - A music social network website that is Last.fm or one exposing a Last.fm compatible API + A music social network website such as Last.fm or + one with a Last.fm-compatible API. """ - def __init__(self, name, homepage, ws_server, api_key, api_secret, session_key, submission_server, username, password_hash, - domain_names, urls): + def __init__( + self, name, homepage, ws_server, api_key, api_secret, session_key, + submission_server, username, password_hash, domain_names, urls): """ name: the name of the network - homepage: the homepage url - ws_server: the url of the webservices server + homepage: the homepage URL + ws_server: the URL of the webservices server api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None - submission_server: the url of the server to which tracks are submitted (scrobbled) + submission_server: the URL of the server to which tracks are + submitted (scrobbled) username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password - domain_names: a dict mapping each DOMAIN_* value to a string domain name - urls: a dict mapping types to urls + password_hash: the output of pylast.md5(password) where password is + the user's password + domain_names: a dict mapping each DOMAIN_* value to a string domain + name + urls: a dict mapping types to URLs - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + Either a valid session_key or a combination of username and + password_hash must be present for scrobbling. - You should use a preconfigured network object through a get_*_network(...) method instead of creating an object + You should use a preconfigured network object through a + get_*_network(...) method instead of creating an object of this class, unless you know what you're doing. """ @@ -171,14 +183,17 @@ class _Network(object): self.last_call_time = 0 self.limit_rate = False - #generate a session_key if necessary - if (self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash): + # Generate a session_key if necessary + if ((self.api_key and self.api_secret) and not self.session_key and + (self.username and self.password_hash)): sk_gen = SessionKeyGenerator(self) - self.session_key = sk_gen.get_session_key(self.username, self.password_hash) + self.session_key = sk_gen.get_session_key( + self.username, self.password_hash) """def __repr__(self): - attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", "session_key", "submission_server", - "username", "password_hash", "domain_names", "urls") + attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", + "session_key", "submission_server", "username", "password_hash", + "domain_names", "urls") text = "pylast._Network(%s)" args = [] @@ -189,7 +204,7 @@ class _Network(object): """ def __str__(self): - return "The %s Network" %self.name + return "The %s Network" % self.name def get_artist(self, artist_name): """ @@ -260,26 +275,31 @@ class _Network(object): Quote from http://www.last.fm/api/submissions: ======== - Client identifiers are used to provide a centrally managed database of - the client versions, allowing clients to be banned if they are found to - be behaving undesirably. The client ID is associated with a version - number on the server, however these are only incremented if a client is - banned and do not have to reflect the version of the actual client application. + Client identifiers are used to provide a centrally managed database + of the client versions, allowing clients to be banned if they are + found to be behaving undesirably. The client ID is associated with + a version number on the server, however these are only incremented + if a client is banned and do not have to reflect the version of the + actual client application. - During development, clients which have not been allocated an identifier should - use the identifier tst, with a version number of 1.0. Do not distribute code or - client implementations which use this test identifier. Do not use the identifiers - used by other clients. + During development, clients which have not been allocated an + identifier should use the identifier tst, with a version number of + 1.0. Do not distribute code or client implementations which use + this test identifier. Do not use the identifiers used by other + clients. ========= To obtain a new client identifier please contact: * Last.fm: submissions@last.fm * # TODO: list others - ...and provide us with the name of your client and its homepage address. + ...and provide us with the name of your client and its homepage + address. """ - _deprecation_warning("Use _Network.scrobble(...), _Network.scrobble_many(...), and Netowrk.update_now_playing(...) instead") + _deprecation_warning( + "Use _Network.scrobble(...), _Network.scrobble_many(...)," + " and Network.update_now_playing(...) instead") return Scrobbler(self, client_id, client_version) @@ -292,7 +312,8 @@ class _Network(object): return self.domain_names[domain_language] def _get_url(self, domain, type): - return "http://%s/%s" %(self._get_language_domain(domain), self.urls[type]) + return "http://%s/%s" % ( + self._get_language_domain(domain), self.urls[type]) def _get_ws_auth(self): """ @@ -385,17 +406,25 @@ class _Network(object): return seq - def get_geo_events(self, long=None, lat=None, location=None, distance=None, tag=None, festivalsonly=None, limit=None, cacheable=True): + def get_geo_events( + self, long=None, lat=None, location=None, distance=None, + tag=None, festivalsonly=None, limit=None, cacheable=True): """ Returns all events in a specific location by country or city name. Parameters: - long (Optional) : Specifies a longitude value to retrieve events for (service returns nearby events by default) - lat (Optional) : Specifies a latitude value to retrieve events for (service returns nearby events by default) - location (Optional) : Specifies a location to retrieve events for (service returns nearby events by default) - distance (Optional) : Find events within a specified radius (in kilometres) + long (Optional) : Specifies a longitude value to retrieve events for + (service returns nearby events by default) + lat (Optional) : Specifies a latitude value to retrieve events for + (service returns nearby events by default) + location (Optional) : Specifies a location to retrieve events for + (service returns nearby events by default) + distance (Optional) : Find events within a specified radius + (in kilometres) tag (Optional) : Specifies a tag to filter by. - festivalsonly[0|1] (Optional) : Whether only festivals should be returned, or all events. - limit (Optional) : The number of results to fetch per page. Defaults to 10. + festivalsonly[0|1] (Optional) : Whether only festivals should be + returned, or all events. + limit (Optional) : The number of results to fetch per page. + Defaults to 10. """ params = {} @@ -416,20 +445,26 @@ class _Network(object): return _extract_events_from_doc(doc, self) def get_metro_weekly_chart_dates(self, cacheable=True): - """Returns a list of From and To tuples for the available metro charts.""" + """ + Returns a list of From and To tuples for the available metro charts. + """ doc = _Request(self, "geo.getMetroWeeklyChartlist").execute(cacheable) seq = [] for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + seq.append((node.getAttribute("from"), node.getAttribute("to"))) return seq def get_metros(self, country=None, cacheable=True): - """Get a list of valid countries and metros for use in the other webservices. + """ + Get a list of valid countries and metros for use in the other + webservices. Parameters: - country (Optional) : Optionally restrict the results to those Metros from a particular country, as defined by the ISO 3166-1 country names standard + country (Optional) : Optionally restrict the results to those Metros + from a particular country, as defined by the ISO 3166-1 country + names standard. """ params = {} @@ -448,12 +483,13 @@ class _Network(object): return seq - def get_geo_top_artists(self, country, limit=None, cacheable=True): """Get the most popular artists on Last.fm by country. Parameters: - country (Required) : A country name, as defined by the ISO 3166-1 country names standard - limit (Optional) : The number of results to fetch per page. Defaults to 50. + country (Required) : A country name, as defined by the ISO 3166-1 + country names standard. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ params = {"country": country} @@ -472,12 +508,16 @@ class _Network(object): return seq - def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True): + def get_geo_top_tracks( + self, country, location=None, limit=None, cacheable=True): """Get the most popular tracks on Last.fm last week by country. Parameters: - country (Required) : A country name, as defined by the ISO 3166-1 country names standard - location (Optional) : A metro name, to fetch the charts for (must be within the country specified) - limit (Optional) : The number of results to fetch per page. Defaults to 50. + country (Required) : A country name, as defined by the ISO 3166-1 + country names standard + location (Optional) : A metro name, to fetch the charts for + (must be within the country specified) + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ params = {"country": country} @@ -531,10 +571,8 @@ class _Network(object): """Return True if web service calls are rate limited""" return self.limit_rate - def enable_caching(self, file_path = None): - """Enables caching request-wide for all cachable calls. - In choosing the backend used for caching, it will try _SqliteCacheBackend first if - the module sqlite3 is present. If not, it will fallback to _ShelfCacheBackend which uses shelve.Shelf objects. + def enable_caching(self, file_path=None): + """Enables caching request-wide for all cacheable calls. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. @@ -578,14 +616,16 @@ class _Network(object): return TagSearch(tag_name, self) def search_for_track(self, artist_name, track_name): - """Searches of a track by its name and its artist. Set artist to an empty string if not available. + """Searches of a track by its name and its artist. Set artist to an + empty string if not available. Returns a TrackSearch object. Use get_next_page() to retrieve sequences of results.""" return TrackSearch(artist_name, track_name, self) def search_for_venue(self, venue_name, country_name): - """Searches of a venue by its name and its country. Set country_name to an empty string if not available. + """Searches of a venue by its name and its country. Set country_name to + an empty string if not available. Returns a VenueSearch object. Use get_next_page() to retrieve sequences of results.""" @@ -618,20 +658,24 @@ class _Network(object): return Album(_extract(doc, "artist"), _extract(doc, "name"), self) - def update_now_playing(self, artist, title, album = None, album_artist = None, - duration = None, track_number = None, mbid = None, context = None): + def update_now_playing( + self, artist, title, album=None, album_artist=None, + duration=None, track_number=None, mbid=None, context=None): """ - Used to notify Last.fm that a user has started listening to a track. + Used to notify Last.fm that a user has started listening to a track. Parameters: artist (Required) : The artist name title (Required) : The track title album (Optional) : The album name. - album_artist (Optional) : The album artist - if this differs from the track artist. + album_artist (Optional) : The album artist - if this differs + from the track artist. duration (Optional) : The length of the track in seconds. - track_number (Optional) : The track number of the track on the album. + track_number (Optional) : The track number of the track on the + album. mbid (Optional) : The MusicBrainz Track ID. - context (Optional) : Sub-client version (not public, only enabled for certain API keys) + context (Optional) : Sub-client version + (not public, only enabled for certain API keys) """ params = {"track": title, "artist": artist} @@ -645,31 +689,43 @@ class _Network(object): _Request(self, "track.updateNowPlaying", params).execute() - def scrobble(self, artist, title, timestamp, album = None, album_artist = None, track_number = None, - duration = None, stream_id = None, context = None, mbid = None): + def scrobble( + self, artist, title, timestamp, album=None, album_artist=None, + track_number=None, duration=None, stream_id=None, context=None, + mbid=None): """Used to add a track-play to a user's profile. Parameters: artist (Required) : The artist name. title (Required) : The track name. - timestamp (Required) : The time the track started playing, in UNIX timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). This must be in the UTC time zone. + timestamp (Required) : The time the track started playing, in UNIX + timestamp format (integer number of seconds since 00:00:00, + January 1st 1970 UTC). This must be in the UTC time zone. album (Optional) : The album name. - album_artist (Optional) : The album artist - if this differs from the track artist. - context (Optional) : Sub-client version (not public, only enabled for certain API keys) - stream_id (Optional) : The stream id for this track received from the radio.getPlaylist service. - track_number (Optional) : The track number of the track on the album. + album_artist (Optional) : The album artist - if this differs from + the track artist. + context (Optional) : Sub-client version (not public, only enabled + for certain API keys) + stream_id (Optional) : The stream id for this track received from + the radio.getPlaylist service. + track_number (Optional) : The track number of the track on the + album. mbid (Optional) : The MusicBrainz Track ID. duration (Optional) : The length of the track in seconds. """ - return self.scrobble_many(({"artist": artist, "title": title, "timestamp": timestamp, "album": album, "album_artist": album_artist, - "track_number": track_number, "duration": duration, "stream_id": stream_id, "context": context, "mbid": mbid},)) + return self.scrobble_many(({ + "artist": artist, "title": title, "timestamp": timestamp, + "album": album, "album_artist": album_artist, + "track_number": track_number, "duration": duration, + "stream_id": stream_id, "context": context, "mbid": mbid},)) def scrobble_many(self, tracks): """ - Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per - track containing the keyword arguments as if passed to the scrobble() method. + Used to scrobble a batch of tracks at once. The parameter tracks is a + sequence of dicts per track containing the keyword arguments as if + passed to the scrobble() method. """ tracks_to_scrobble = tracks[:50] @@ -684,8 +740,13 @@ class _Network(object): params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"] params["track[%d]" % i] = tracks_to_scrobble[i]["title"] - additional_args = ("timestamp", "album", "album_artist", "context", "stream_id", "track_number", "mbid", "duration") - args_map_to = {"album_artist": "albumArtist", "track_number": "trackNumber", "stream_id": "streamID"} # so friggin lazy + additional_args = ( + "timestamp", "album", "album_artist", "context", + "stream_id", "track_number", "mbid", "duration") + args_map_to = { # so friggin lazy + "album_artist": "albumArtist", + "track_number": "trackNumber", + "stream_id": "streamID"} for arg in additional_args: @@ -695,8 +756,8 @@ class _Network(object): else: maps_to = arg - params["%s[%d]" %(maps_to, i)] = tracks_to_scrobble[i][arg] - + params[ + "%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg] _Request(self, "track.scrobble", params).execute() @@ -712,10 +773,10 @@ class _Network(object): params['artist[' + str(i) + ']'] = thing elif type == "album": params['artist[' + str(i) + ']'] = thing.artist - params['album[' + str(i) + ']'] = thing.title + params['album[' + str(i) + ']'] = thing.title elif type == "track": params['artist[' + str(i) + ']'] = thing.artist - params['track[' + str(i) + ']'] = thing.title + params['track[' + str(i) + ']'] = thing.title doc = _Request(self, method, params).execute(cacheable) @@ -736,6 +797,7 @@ class _Network(object): def get_track_play_links(self, tracks, cacheable=True): return self.get_play_links("track", tracks) + class LastFMNetwork(_Network): """A Last.fm network object @@ -744,63 +806,76 @@ class LastFMNetwork(_Network): api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password + password_hash: the output of pylast.md5(password) where password is the + user's password - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + Either a valid session_key or a combination of username and password_hash + must be present for scrobbling. - Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: + Most read-only webservices only require an api_key and an api_secret, see + about obtaining them from: http://www.last.fm/api/account """ - def __init__(self, api_key="", api_secret="", session_key="", username="", password_hash=""): - _Network.__init__(self, - name = "Last.fm", - homepage = "http://last.fm", - ws_server = ("ws.audioscrobbler.com", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://post.audioscrobbler.com:80/", - username = username, - password_hash = password_hash, - domain_names = { - DOMAIN_ENGLISH: 'www.last.fm', - DOMAIN_GERMAN: 'www.lastfm.de', - DOMAIN_SPANISH: 'www.lastfm.es', - DOMAIN_FRENCH: 'www.lastfm.fr', - DOMAIN_ITALIAN: 'www.lastfm.it', - DOMAIN_POLISH: 'www.lastfm.pl', - DOMAIN_PORTUGUESE: 'www.lastfm.com.br', - DOMAIN_SWEDISH: 'www.lastfm.se', - DOMAIN_TURKISH: 'www.lastfm.com.tr', - DOMAIN_RUSSIAN: 'www.lastfm.ru', - DOMAIN_JAPANESE: 'www.lastfm.jp', - DOMAIN_CHINESE: 'cn.last.fm', - }, - urls = { - "album": "music/%(artist)s/%(album)s", - "artist": "music/%(artist)s", - "event": "event/%(id)s", - "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", - "tag": "tag/%(name)s", - "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", - "user": "user/%(name)s", - } - ) + def __init__( + self, api_key="", api_secret="", session_key="", username="", + password_hash=""): + _Network.__init__( + self, + name="Last.fm", + homepage="http://last.fm", + ws_server=("ws.audioscrobbler.com", "/2.0/"), + api_key = api_key, + api_secret = api_secret, + session_key = session_key, + submission_server = "http://post.audioscrobbler.com:80/", + username = username, + password_hash = password_hash, + domain_names = { + DOMAIN_ENGLISH: 'www.last.fm', + DOMAIN_GERMAN: 'www.lastfm.de', + DOMAIN_SPANISH: 'www.lastfm.es', + DOMAIN_FRENCH: 'www.lastfm.fr', + DOMAIN_ITALIAN: 'www.lastfm.it', + DOMAIN_POLISH: 'www.lastfm.pl', + DOMAIN_PORTUGUESE: 'www.lastfm.com.br', + DOMAIN_SWEDISH: 'www.lastfm.se', + DOMAIN_TURKISH: 'www.lastfm.com.tr', + DOMAIN_RUSSIAN: 'www.lastfm.ru', + DOMAIN_JAPANESE: 'www.lastfm.jp', + DOMAIN_CHINESE: 'cn.last.fm', + }, + urls = { + "album": "music/%(artist)s/%(album)s", + "artist": "music/%(artist)s", + "event": "event/%(id)s", + "country": "place/%(country_name)s", + "playlist": "user/%(user)s/library/playlists/%(appendix)s", + "tag": "tag/%(name)s", + "track": "music/%(artist)s/_/%(title)s", + "group": "group/%(name)s", + "user": "user/%(name)s", + } + ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, - "'%s'" %self.username, "'%s'" %self.password_hash))) + return "pylast.LastFMNetwork(%s)" % (", ".join( + ("'%s'" % self.api_key, + "'%s'" % self.api_secret, + "'%s'" % self.session_key, + "'%s'" % self.username, + "'%s'" % self.password_hash))) def __str__(self): return "Last.fm Network" -def get_lastfm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + +def get_lastfm_network( + api_key="", api_secret="", session_key="", username="", + password_hash=""): """ Returns a preconfigured _Network object for Last.fm @@ -808,20 +883,25 @@ def get_lastfm_network(api_key="", api_secret="", session_key = "", username = " api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password + password_hash: the output of pylast.md5(password) where password is the + user's password - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. - Either a valid session_key or a combination of username and password_hash must be present for scrobbling. + Either a valid session_key or a combination of username and password_hash + must be present for scrobbling. - Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: + Most read-only webservices only require an api_key and an api_secret, see + about obtaining them from: http://www.last.fm/api/account """ _deprecation_warning("Create a LastFMNetwork object instead") - return LastFMNetwork(api_key, api_secret, session_key, username, password_hash) + return LastFMNetwork( + api_key, api_secret, session_key, username, password_hash) + class LibreFMNetwork(_Network): """ @@ -831,59 +911,70 @@ class LibreFMNetwork(_Network): api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password + password_hash: the output of pylast.md5(password) where password is the + user's password - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. """ - def __init__(self, api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + def __init__( + self, api_key="", api_secret="", session_key="", username="", + password_hash=""): - _Network.__init__(self, - name = "Libre.fm", - homepage = "http://alpha.libre.fm", - ws_server = ("alpha.libre.fm", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://turtle.libre.fm:80/", - username = username, - password_hash = password_hash, - domain_names = { - DOMAIN_ENGLISH: "alpha.libre.fm", - DOMAIN_GERMAN: "alpha.libre.fm", - DOMAIN_SPANISH: "alpha.libre.fm", - DOMAIN_FRENCH: "alpha.libre.fm", - DOMAIN_ITALIAN: "alpha.libre.fm", - DOMAIN_POLISH: "alpha.libre.fm", - DOMAIN_PORTUGUESE: "alpha.libre.fm", - DOMAIN_SWEDISH: "alpha.libre.fm", - DOMAIN_TURKISH: "alpha.libre.fm", - DOMAIN_RUSSIAN: "alpha.libre.fm", - DOMAIN_JAPANESE: "alpha.libre.fm", - DOMAIN_CHINESE: "alpha.libre.fm", - }, - urls = { - "album": "artist/%(artist)s/album/%(album)s", - "artist": "artist/%(artist)s", - "event": "event/%(id)s", - "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", - "tag": "tag/%(name)s", - "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", - "user": "user/%(name)s", - } - ) + _Network.__init__( + self, + name="Libre.fm", + homepage="http://alpha.libre.fm", + ws_server=("alpha.libre.fm", "/2.0/"), + api_key = api_key, + api_secret = api_secret, + session_key = session_key, + submission_server = "http://turtle.libre.fm:80/", + username = username, + password_hash = password_hash, + domain_names = { + DOMAIN_ENGLISH: "alpha.libre.fm", + DOMAIN_GERMAN: "alpha.libre.fm", + DOMAIN_SPANISH: "alpha.libre.fm", + DOMAIN_FRENCH: "alpha.libre.fm", + DOMAIN_ITALIAN: "alpha.libre.fm", + DOMAIN_POLISH: "alpha.libre.fm", + DOMAIN_PORTUGUESE: "alpha.libre.fm", + DOMAIN_SWEDISH: "alpha.libre.fm", + DOMAIN_TURKISH: "alpha.libre.fm", + DOMAIN_RUSSIAN: "alpha.libre.fm", + DOMAIN_JAPANESE: "alpha.libre.fm", + DOMAIN_CHINESE: "alpha.libre.fm", + }, + urls = { + "album": "artist/%(artist)s/album/%(album)s", + "artist": "artist/%(artist)s", + "event": "event/%(id)s", + "country": "place/%(country_name)s", + "playlist": "user/%(user)s/library/playlists/%(appendix)s", + "tag": "tag/%(name)s", + "track": "music/%(artist)s/_/%(title)s", + "group": "group/%(name)s", + "user": "user/%(name)s", + } + ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" %(", ".join(("'%s'" %self.api_key, "'%s'" %self.api_secret, "'%s'" %self.session_key, - "'%s'" %self.username, "'%s'" %self.password_hash))) + return "pylast.LibreFMNetwork(%s)" % (", ".join( + ("'%s'" % self.api_key, + "'%s'" % self.api_secret, + "'%s'" % self.session_key, + "'%s'" % self.username, + "'%s'" % self.password_hash))) def __str__(self): return "Libre.fm Network" -def get_librefm_network(api_key="", api_secret="", session_key = "", username = "", password_hash = ""): + +def get_librefm_network( + api_key="", api_secret="", session_key="", username="", + password_hash=""): """ Returns a preconfigured _Network object for Libre.fm @@ -891,19 +982,23 @@ def get_librefm_network(api_key="", api_secret="", session_key = "", username = api_secret: a provided API_SECRET session_key: a generated session_key or None username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the user's password + password_hash: the output of pylast.md5(password) where password is the + user's password - if username and password_hash were provided and not session_key, session_key will be - generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. """ - _deprecation_warning("DeprecationWarning: Create a LibreFMNetwork object instead") + _deprecation_warning( + "DeprecationWarning: Create a LibreFMNetwork object instead") + + return LibreFMNetwork( + api_key, api_secret, session_key, username, password_hash) - return LibreFMNetwork(api_key, api_secret, session_key, username, password_hash) class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" - def __init__(self, file_path = None): + def __init__(self, file_path=None): self.shelf = shelve.open(file_path) def get_xml(self, key): @@ -915,10 +1010,11 @@ class _ShelfCacheBackend(object): def has_key(self, key): return key in self.shelf.keys() + class _Request(object): """Representing an abstract web service operation.""" - def __init__(self, network, method_name, params = {}): + def __init__(self, network, method_name, params={}): self.network = network self.params = {} @@ -926,7 +1022,8 @@ class _Request(object): for key in params: self.params[key] = _unicode(params[key]) - (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() + (self.api_key, self.api_secret, self.session_key) = \ + network._get_ws_auth() self.params["api_key"] = self.api_key self.params["method"] = method_name @@ -945,7 +1042,9 @@ class _Request(object): self.params['api_sig'] = self._get_signature() def _get_signature(self): - """Returns a 32-character hexadecimal md5 hash of the signature string.""" + """ + Returns a 32-character hexadecimal md5 hash of the signature string. + """ keys = list(self.params.keys()) @@ -962,7 +1061,9 @@ class _Request(object): return md5(string) def _get_cache_key(self): - """The cache key is a string of concatenated sorted names and values.""" + """ + The cache key is a string of concatenated sorted names and values. + """ keys = list(self.params.keys()) keys.sort() @@ -997,22 +1098,25 @@ class _Request(object): data = [] for name in self.params.keys(): - data.append('='.join((name, url_quote_plus(_string(self.params[name]))))) + data.append('='.join(( + name, url_quote_plus(_string(self.params[name]))))) data = '&'.join(data) headers = { "Content-type": "application/x-www-form-urlencoded", 'Accept-Charset': 'utf-8', 'User-Agent': "pylast" + '/' + __version__ - } + } (HOST_NAME, HOST_SUBDIR) = self.network.ws_server if self.network.is_proxy_enabled(): - conn = HTTPConnection(host = self._get_proxy()[0], port = self._get_proxy()[1]) + conn = HTTPConnection( + host=self._get_proxy()[0], port=self._get_proxy()[1]) try: - conn.request(method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + conn.request( + method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -1021,7 +1125,8 @@ class _Request(object): conn = HTTPConnection(host=HOST_NAME) try: - conn.request(method='POST', url=HOST_SUBDIR, body=data, headers=headers) + conn.request( + method='POST', url=HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -1035,7 +1140,7 @@ class _Request(object): self._check_response_for_errors(response_text) return response_text - def execute(self, cacheable = False): + def execute(self, cacheable=False): """Returns the XML DOM response of the POST Request from the server""" if self.network.is_caching_enabled() and cacheable: @@ -1061,6 +1166,7 @@ class _Request(object): details = e.firstChild.data.strip() raise WSError(self.network, status, details) + class SessionKeyGenerator(object): """Methods of generating a session key: 1) Web Authentication: @@ -1073,13 +1179,16 @@ class SessionKeyGenerator(object): a. network = get_*_network(API_KEY, API_SECRET) b. username = raw_input("Please enter your username: ") c. password_hash = pylast.md5(raw_input("Please enter your password: ") - d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) + d. session_key = SessionKeyGenerator(network).get_session_key(username, + password_hash) - A session key's lifetime is infinite, unless the user provokes the rights of the given API Key. + A session key's lifetime is infinite, unless the user provokes the rights + of the given API Key. - If you create a Network object with just a API_KEY and API_SECRET and a username and a password_hash, a - SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this - manually, unless you want to. + If you create a Network object with just a API_KEY and API_SECRET and a + username and a password_hash, a SESSION_KEY will be automatically generated + for that network and stored in it so you don't have to do this manually, + unless you want to. """ def __init__(self, network): @@ -1087,8 +1196,10 @@ class SessionKeyGenerator(object): self.web_auth_tokens = {} def _get_web_auth_token(self): - """Retrieves a token from the network for web authentication. - The token then has to be authorized from getAuthURL before creating session. + """ + Retrieves a token from the network for web authentication. + The token then has to be authorized from getAuthURL before creating + session. """ request = _Request(self.network, 'auth.getToken') @@ -1103,24 +1214,32 @@ class SessionKeyGenerator(object): return e.firstChild.data def get_web_auth_url(self): - """The user must open this page, and you first, then call get_web_auth_session_key(url) after that.""" + """ + The user must open this page, and you first, then + call get_web_auth_session_key(url) after that. + """ token = self._get_web_auth_token() url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ - {"homepage": self.network.homepage, "api": self.network.api_key, "token": token} + {"homepage": self.network.homepage, + "api": self.network.api_key, "token": token} self.web_auth_tokens[url] = token return url def get_web_auth_session_key(self, url): - """Retrieves the session key of a web authorization process by its url.""" + """ + Retrieves the session key of a web authorization process by its url. + """ if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] else: - token = "" #that's gonna raise a WSError of an unauthorized token when the request is executed. + # That's going to raise a WSError of an unauthorized token when the + # request is executed. + token = "" request = _Request(self.network, 'auth.getSession', {'token': token}) @@ -1133,9 +1252,13 @@ class SessionKeyGenerator(object): return doc.getElementsByTagName('key')[0].firstChild.data def get_session_key(self, username, password_hash): - """Retrieve a session key with a username and a md5 hash of the user's password.""" + """ + Retrieve a session key with a username and a md5 hash of the user's + password. + """ - params = {"username": username, "authToken": md5(username + password_hash)} + params = { + "username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) # default action is that a request is signed only when @@ -1148,12 +1271,21 @@ class SessionKeyGenerator(object): TopItem = collections.namedtuple("TopItem", ["item", "weight"]) SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) -LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) -PlayedTrack = collections.namedtuple("PlayedTrack", ["track", "album", "playback_date", "timestamp"]) -LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) -ImageSizes = collections.namedtuple("ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"]) -Image = collections.namedtuple("Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"]) -Shout = collections.namedtuple("Shout", ["body", "author", "date"]) +LibraryItem = collections.namedtuple( + "LibraryItem", ["item", "playcount", "tagcount"]) +PlayedTrack = collections.namedtuple( + "PlayedTrack", ["track", "album", "playback_date", "timestamp"]) +LovedTrack = collections.namedtuple( + "LovedTrack", ["track", "date", "timestamp"]) +ImageSizes = collections.namedtuple( + "ImageSizes", [ + "original", "large", "largesquare", "medium", "small", "extralarge"]) +Image = collections.namedtuple( + "Image", [ + "title", "url", "dateadded", "format", "owner", "sizes", "votes"]) +Shout = collections.namedtuple( + "Shout", ["body", "author", "date"]) + def _string_output(funct): def r(*args): @@ -1161,7 +1293,8 @@ def _string_output(funct): return r -def _pad_list(given_list, desired_length, padding = None): + +def _pad_list(given_list, desired_length, padding=None): """ Pads a list to be of the desired_length. """ @@ -1171,6 +1304,7 @@ def _pad_list(given_list, desired_length, padding = None): return given_list + class _BaseObject(object): """An abstract webservices object.""" @@ -1179,7 +1313,7 @@ class _BaseObject(object): def __init__(self, network): self.network = network - def _request(self, method_name, cacheable = False, params = None): + def _request(self, method_name, cacheable=False, params=None): if not params: params = self._get_params() @@ -1194,13 +1328,16 @@ class _BaseObject(object): # Convert any ints (or whatever) into strings values = map(str, self._get_params().values()) - return hash(self.network) + \ - hash(str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower()) + return hash(self.network) + hash(str(type(self)) + "".join( + list(self._get_params().keys()) + list(values) + ).lower()) def _extract_cdata_from_request(self, method_name, tag_name, params): doc = self._request(method_name, True, params) - return doc.getElementsByTagName(tag_name)[0].firstChild.wholeText.strip() + return doc.getElementsByTagName( + tag_name)[0].firstChild.wholeText.strip() + class _Taggable(object): """Common functions for classes with tags.""" @@ -1320,6 +1457,7 @@ class _Taggable(object): return seq + class WSError(Exception): """Exception related to the Network web service""" @@ -1351,6 +1489,7 @@ class WSError(Exception): return self.status + class MalformedResponseError(Exception): """Exception conveying a malformed response from Last.fm.""" @@ -1359,7 +1498,9 @@ class MalformedResponseError(Exception): self.underlying_error = underlying_error def __str__(self): - return "Malformed response from Last.fm. Underlying error: %s" %str(self.underlying_error) + return "Malformed response from Last.fm. Underlying error: %s" % str( + self.underlying_error) + class NetworkError(Exception): """Exception conveying a problem in sending a request to Last.fm""" @@ -1369,7 +1510,8 @@ class NetworkError(Exception): self.underlying_error = underlying_error def __str__(self): - return "NetworkError: %s" %str(self.underlying_error) + return "NetworkError: %s" % str(self.underlying_error) + class Album(_BaseObject, _Taggable): """An album.""" @@ -1400,20 +1542,32 @@ class Album(_BaseObject, _Taggable): self.username = username def __repr__(self): - return "pylast.Album(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) + return "pylast.Album(%s, %s, %s)" % ( + repr(self.artist.name), repr(self.title), repr(self.network)) @_string_output def __str__(self): - return _unicode("%s - %s") %(self.get_artist().get_name(), self.get_title()) + return _unicode("%s - %s") % ( + self.get_artist().get_name(), self.get_title()) def __eq__(self, other): - return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) + a = self.get_title().lower() + b = other.get_title().lower() + c = self.get_artist().get_name().lower() + d = other.get_artist().get_name().lower() + return (a == b) and (c == d) def __ne__(self, other): - return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) + a = self.get_title().lower() + b = other.get_title().lower() + c = self.get_artist().get_name().lower() + d = other.get_artist().get_name().lower() + return (a != b) or (c != d) def _get_params(self): - return {'artist': self.get_artist().get_name(), 'album': self.get_title(), } + return { + 'artist': self.get_artist().get_name(), 'album': self.get_title(), + } def get_artist(self): """Returns the associated Artist object.""" @@ -1433,9 +1587,10 @@ class Album(_BaseObject, _Taggable): def get_release_date(self): """Retruns the release date of the album.""" - return _extract(self._request("album.getInfo", cacheable = True), "releasedate") + return _extract( + self._request("album.getInfo", cacheable=True), "releasedate") - def get_cover_image(self, size = COVER_EXTRA_LARGE): + def get_cover_image(self, size=COVER_EXTRA_LARGE): """ Returns a uri to the cover image size can be one of: @@ -1445,17 +1600,19 @@ class Album(_BaseObject, _Taggable): COVER_SMALL """ - return _extract_all(self._request("album.getInfo", cacheable = True), 'image')[size] + return _extract_all( + self._request("album.getInfo", cacheable=True), 'image')[size] def get_id(self): """Returns the ID""" - return _extract(self._request("album.getInfo", cacheable = True), "id") + return _extract(self._request("album.getInfo", cacheable=True), "id") def get_playcount(self): """Returns the number of plays on the network""" - return _number(_extract(self._request("album.getInfo", cacheable = True), "playcount")) + return _number(_extract( + self._request("album.getInfo", cacheable=True), "playcount")) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1465,26 +1622,28 @@ class Album(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - return _number(_extract(self._request("album.getInfo", True, params), "userplaycount")) + return _number(_extract( + self._request("album.getInfo", True, params), "userplaycount")) def get_listener_count(self): """Returns the number of listeners on the network""" - return _number(_extract(self._request("album.getInfo", cacheable = True), "listeners")) + return _number(_extract( + self._request("album.getInfo", cacheable=True), "listeners")) def get_tracks(self): """Returns the list of Tracks on this album.""" - uri = 'lastfm://playlist/album/%s' %self.get_id() + uri = 'lastfm://playlist/album/%s' % self.get_id() return XSPF(uri, self.network).get_tracks() def get_mbid(self): """Returns the MusicBrainz id of the album.""" - return _extract(self._request("album.getInfo", cacheable = True), "mbid") + return _extract(self._request("album.getInfo", cacheable=True), "mbid") - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the album page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: @@ -1505,7 +1664,8 @@ class Album(_BaseObject, _Taggable): artist = _url_safe(self.get_artist().get_name()) album = _url_safe(self.get_title()) - return self.network._get_url(domain_name, "album") %{'artist': artist, 'album': album} + return self.network._get_url( + domain_name, "album") % {'artist': artist, 'album': album} def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" @@ -1543,6 +1703,7 @@ class Album(_BaseObject, _Taggable): return _extract(node, "content") + class Artist(_BaseObject, _Taggable): """An artist.""" @@ -1564,7 +1725,8 @@ class Artist(_BaseObject, _Taggable): self.username = username def __repr__(self): - return "pylast.Artist(%s, %s)" %(repr(self.get_name()), repr(self.network)) + return "pylast.Artist(%s, %s)" % ( + repr(self.get_name()), repr(self.network)) @_string_output def __str__(self): @@ -1589,7 +1751,7 @@ class Artist(_BaseObject, _Taggable): return self.name - def get_cover_image(self, size = COVER_MEGA): + def get_cover_image(self, size=COVER_MEGA): """ Returns a uri to the cover image size can be one of: @@ -1600,12 +1762,14 @@ class Artist(_BaseObject, _Taggable): COVER_SMALL """ - return _extract_all(self._request("artist.getInfo", True), "image")[size] + return _extract_all( + self._request("artist.getInfo", True), "image")[size] def get_playcount(self): """Returns the number of plays on the network.""" - return _number(_extract(self._request("artist.getInfo", True), "playcount")) + return _number(_extract( + self._request("artist.getInfo", True), "playcount")) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1615,7 +1779,8 @@ class Artist(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - return _number(_extract(self._request("artist.getInfo", True, params), "userplaycount")) + return _number(_extract( + self._request("artist.getInfo", True, params), "userplaycount")) def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" @@ -1630,13 +1795,15 @@ class Artist(_BaseObject, _Taggable): if hasattr(self, "listener_count"): return self.listener_count else: - self.listener_count = _number(_extract(self._request("artist.getInfo", True), "listeners")) + self.listener_count = _number(_extract( + self._request("artist.getInfo", True), "listeners")) return self.listener_count def is_streamable(self): """Returns True if the artist is streamable.""" - return bool(_number(_extract(self._request("artist.getInfo", True), "streamable"))) + return bool(_number(_extract( + self._request("artist.getInfo", True), "streamable"))) def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" @@ -1652,7 +1819,8 @@ class Artist(_BaseObject, _Taggable): else: params = None - return self._extract_cdata_from_request("artist.getInfo", "summary", params) + return self._extract_cdata_from_request( + "artist.getInfo", "summary", params) def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" @@ -1663,7 +1831,8 @@ class Artist(_BaseObject, _Taggable): else: params = None - return self._extract_cdata_from_request("artist.getInfo", "content", params) + return self._extract_cdata_from_request( + "artist.getInfo", "content", params) def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" @@ -1672,7 +1841,7 @@ class Artist(_BaseObject, _Taggable): return _extract_events_from_doc(doc, self.network) - def get_similar(self, limit = None): + def get_similar(self, limit=None): """Returns the similar artists on the network.""" params = self._get_params() @@ -1686,7 +1855,8 @@ class Artist(_BaseObject, _Taggable): artists = [] for i in range(0, len(names)): - artists.append(SimilarItem(Artist(names[i], self.network), _number(matches[i]))) + artists.append(SimilarItem( + Artist(names[i], self.network), _number(matches[i]))) return artists @@ -1718,11 +1888,11 @@ class Artist(_BaseObject, _Taggable): artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) - seq.append( TopItem(Track(artist, title, self.network), playcount) ) + seq.append(TopItem(Track(artist, title, self.network), playcount)) return seq - def get_top_fans(self, limit = None): + def get_top_fans(self, limit=None): """Returns a list of the Users who played this artist the most. # Parameters: * limit int: Max elements. @@ -1745,14 +1915,15 @@ class Artist(_BaseObject, _Taggable): return seq - def share(self, users, message = None): + def share(self, users, message=None): """Shares this artist (sends out recommendations). # Parameters: - * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. + * users [User|str,]: A list that can contain usernames, emails, + User objects, or all of them. * message str: A message to include in the recommendation message. """ - #last.fm currently accepts a max of 10 recipient at a time + # Last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] @@ -1773,7 +1944,7 @@ class Artist(_BaseObject, _Taggable): self._request('artist.share', False, params) - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: @@ -1793,7 +1964,8 @@ class Artist(_BaseObject, _Taggable): artist = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "artist") %{'artist': artist} + return self.network._get_url( + domain_name, "artist") % {'artist': artist} def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): """ @@ -1815,16 +1987,17 @@ class Artist(_BaseObject, _Taggable): else: user = None - images.append(Image( - _extract(e, "title"), - _extract(e, "url"), - _extract(e, "dateadded"), - _extract(e, "format"), - user, - ImageSizes(*_extract_all(e, "size")), - (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) - ) - ) + images.append( + Image( + _extract(e, "title"), + _extract(e, "url"), + _extract(e, "dateadded"), + _extract(e, "format"), + user, + ImageSizes(*_extract_all(e, "size")), + (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) + ) + ) return images def get_shouts(self, limit=50, cacheable=False): @@ -1834,12 +2007,13 @@ class Artist(_BaseObject, _Taggable): shouts = [] for node in _collect_nodes(limit, self, "artist.getShouts", cacheable): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) + shouts.append( + Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) return shouts def shout(self, message): @@ -1866,7 +2040,7 @@ class Event(_BaseObject): self.id = event_id def __repr__(self): - return "pylast.Event(%s, %s)" %(repr(self.id), repr(self.network)) + return "pylast.Event(%s, %s)" % (repr(self.id), repr(self.network)) @_string_output def __str__(self): @@ -1962,7 +2136,7 @@ class Event(_BaseObject): return _extract(doc, "description") - def get_cover_image(self, size = COVER_MEGA): + def get_cover_image(self, size=COVER_MEGA): """ Returns a uri to the cover image size can be one of: @@ -1991,7 +2165,7 @@ class Event(_BaseObject): return _number(_extract(doc, "reviews")) - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -2008,15 +2182,17 @@ class Event(_BaseObject): o DOMAIN_CHINESE """ - return self.network._get_url(domain_name, "event") %{'id': self.get_id()} + return self.network._get_url( + domain_name, "event") % {'id': self.get_id()} - def share(self, users, message = None): + def share(self, users, message=None): """Shares this event (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, or all of them. + * users: A list that can contain usernames, emails, User objects, + or all of them. * message: A message to include in the recommendation message. """ - #last.fm currently accepts a max of 10 recipient at a time + # Last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] @@ -2044,12 +2220,13 @@ class Event(_BaseObject): shouts = [] for node in _collect_nodes(limit, self, "event.getShouts", cacheable): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) + shouts.append( + Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) return shouts def shout(self, message): @@ -2062,6 +2239,7 @@ class Event(_BaseObject): self._request("event.Shout", False, params) + class Country(_BaseObject): """A country at Last.fm.""" @@ -2075,7 +2253,7 @@ class Country(_BaseObject): self.name = name def __repr__(self): - return "pylast.Country(%s, %s)" %(repr(self.name), repr(self.network)) + return "pylast.Country(%s, %s)" % (repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -2091,7 +2269,8 @@ class Country(_BaseObject): return {'country': self.get_name()} def _get_name_from_code(self, alpha2code): - # TODO: Have this function lookup the alpha-2 code and return the country name. + # TODO: Have this function lookup the alpha-2 code and return the + # country name. return alpha2code @@ -2127,11 +2306,11 @@ class Country(_BaseObject): artist = _extract(n, 'name', 1) playcount = _number(_extract(n, "playcount")) - seq.append( TopItem(Track(artist, title, self.network), playcount)) + seq.append(TopItem(Track(artist, title, self.network), playcount)) return seq - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the event page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -2150,7 +2329,8 @@ class Country(_BaseObject): country_name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "country") %{'country_name': country_name} + return self.network._get_url( + domain_name, "country") % {'country_name': country_name} class Metro(_BaseObject): @@ -2168,19 +2348,20 @@ class Metro(_BaseObject): self.country = country def __repr__(self): - return "pylast.Metro(%s, %s, %s)" %(repr(self.name), repr(self.country), repr(self.network)) + return "pylast.Metro(%s, %s, %s)" % ( + repr(self.name), repr(self.country), repr(self.network)) @_string_output def __str__(self): return self.get_name() + ", " + self.get_country() def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() and \ - self.get_country().lower() == other.get_country().lower() + return (self.get_name().lower() == other.get_name().lower() and + self.get_country().lower() == other.get_country().lower()) def __ne__(self, other): - return self.get_name() != other.get_name() or \ - self.get_country().lower() != other.get_country().lower() + return (self.get_name() != other.get_name() or + self.get_country().lower() != other.get_country().lower()) def _get_params(self): return {'metro': self.get_name(), 'country': self.get_country()} @@ -2195,7 +2376,9 @@ class Metro(_BaseObject): return self.country - def _get_chart(self, method, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): + def _get_chart( + self, method, tag="artist", limit=None, from_date=None, + to_date=None, cacheable=True): """Internal helper for getting geo charts.""" params = self._get_params() if limit: params["limit"] = limit @@ -2220,59 +2403,97 @@ class Metro(_BaseObject): return seq - def get_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): + def get_artist_chart( + self, tag="artist", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of artists for a metro. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroArtistChart", tag=tag, limit=limit, + from_date=from_date, to_date=to_date, cacheable=cacheable) - def get_hype_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): + def get_hype_artist_chart( + self, tag="artist", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of hyped (up and coming) artists for a metro. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroHypeArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroHypeArtistChart", tag=tag, limit=limit, + from_date=from_date, to_date=to_date, cacheable=cacheable) - def get_unique_artist_chart(self, tag="artist", limit=None, from_date=None, to_date=None, cacheable=True): + def get_unique_artist_chart( + self, tag="artist", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of the artists which make that metro unique. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroUniqueArtistChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroUniqueArtistChart", tag=tag, limit=limit, + from_date=from_date, to_date=to_date, cacheable=cacheable) - def get_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + def get_track_chart( + self, tag="track", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of tracks for a metro. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroTrackChart", tag=tag, limit=limit, + from_date=from_date, to_date=to_date, cacheable=cacheable) - def get_hype_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + def get_hype_track_chart( + self, tag="track", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of tracks for a metro. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroHypeTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroHypeTrackChart", tag=tag, + limit=limit, from_date=from_date, to_date=to_date, + cacheable=cacheable) - def get_unique_track_chart(self, tag="track", limit=None, from_date=None, to_date=None, cacheable=True): + def get_unique_track_chart( + self, tag="track", limit=None, from_date=None, to_date=None, + cacheable=True): """Get a chart of tracks for a metro. Parameters: - from_date (Optional) : Beginning timestamp of the weekly range requested + from_date (Optional) : Beginning timestamp of the weekly range + requested to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. Defaults to 50. + limit (Optional) : The number of results to fetch per page. + Defaults to 50. """ - return self._get_chart("geo.getMetroUniqueTrackChart", tag = tag, limit = limit, from_date = from_date, to_date = to_date, cacheable = cacheable) + return self._get_chart( + "geo.getMetroUniqueTrackChart", tag=tag, limit=limit, + from_date=from_date, to_date=to_date, cacheable=cacheable) + class Library(_BaseObject): """A user's Last.fm library.""" @@ -2294,7 +2515,7 @@ class Library(_BaseObject): self._tracks_index = 0 def __repr__(self): - return "pylast.Library(%s, %s)" %(repr(self.user), repr(self.network)) + return "pylast.Library(%s, %s)" % (repr(self.user), repr(self.network)) @_string_output def __str__(self): @@ -2359,7 +2580,8 @@ class Library(_BaseObject): def get_albums(self, artist=None, limit=50, cacheable=True): """ Returns a sequence of Album objects - If no artist is specified, it will return all, sorted by decreasing play count. + If no artist is specified, it will return all, sorted by decreasing + play count. If limit==None it will return all (may take a while) """ @@ -2368,13 +2590,15 @@ class Library(_BaseObject): params["artist"] = artist seq = [] - for node in _collect_nodes(limit, self, "library.getAlbums", cacheable, params): + for node in _collect_nodes( + limit, self, "library.getAlbums", cacheable, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - seq.append(LibraryItem(Album(artist, name, self.network), playcount, tagcount)) + seq.append(LibraryItem( + Album(artist, name, self.network), playcount, tagcount)) return seq @@ -2385,13 +2609,15 @@ class Library(_BaseObject): """ seq = [] - for node in _collect_nodes(limit, self, "library.getArtists", cacheable): + for node in _collect_nodes( + limit, self, "library.getArtists", cacheable): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) + seq.append(LibraryItem( + Artist(name, self.network), playcount, tagcount)) return seq @@ -2408,13 +2634,15 @@ class Library(_BaseObject): params["album"] = album seq = [] - for node in _collect_nodes(limit, self, "library.getTracks", cacheable, params): + for node in _collect_nodes( + limit, self, "library.getTracks", cacheable, params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - seq.append(LibraryItem(Track(artist, name, self.network), playcount, tagcount)) + seq.append(LibraryItem( + Track(artist, name, self.network), playcount, tagcount)) return seq @@ -2457,7 +2685,9 @@ class Playlist(_BaseObject): return repr(self.user) + "'s playlist # " + repr(self.id) def _get_info_node(self): - """Returns the node from user.getPlaylists where this playlist's info is.""" + """ + Returns the node from user.getPlaylists where this playlist's info is. + """ doc = self._request("user.getPlaylists", True) @@ -2481,7 +2711,7 @@ class Playlist(_BaseObject): def get_tracks(self): """Returns a list of the tracks on this user playlist.""" - uri = _unicode('lastfm://playlist/%s') %self.get_id() + uri = _unicode('lastfm://playlist/%s') % self.get_id() return XSPF(uri, self.network).get_tracks() @@ -2520,8 +2750,10 @@ class Playlist(_BaseObject): return _number(_extract(self._get_info_node(), "duration")) def is_streamable(self): - """Returns True if the playlist is streamable. - For a playlist to be streamable, it needs at least 45 tracks by 15 different artists.""" + """ + Returns True if the playlist is streamable. + For a playlist to be streamable, it needs at least 45 tracks by 15 + different artists.""" if _extract(self._get_info_node(), "streamable") == '1': return True @@ -2535,7 +2767,7 @@ class Playlist(_BaseObject): return track in self.get_tracks() - def get_cover_image(self, size = COVER_EXTRA_LARGE): + def get_cover_image(self, size=COVER_EXTRA_LARGE): """ Returns a uri to the cover image size can be one of: @@ -2548,7 +2780,7 @@ class Playlist(_BaseObject): return _extract(self._get_info_node(), "image")[size] - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the playlist on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -2568,13 +2800,15 @@ class Playlist(_BaseObject): english_url = _extract(self._get_info_node(), "url") appendix = english_url[english_url.rfind("/") + 1:] - return self.network._get_url(domain_name, "playlist") %{'appendix': appendix, "user": self.get_user().get_name()} + return self.network._get_url(domain_name, "playlist") % { + 'appendix': appendix, "user": self.get_user().get_name()} class Tag(_BaseObject): """A Last.fm object tag.""" - # TODO: getWeeklyArtistChart (too lazy, i'll wait for when someone requests it) + # TODO: getWeeklyArtistChart + # (too lazy, i'll wait for when someone requests it) name = None @@ -2586,7 +2820,7 @@ class Tag(_BaseObject): self.name = name def __repr__(self): - return "pylast.Tag(%s, %s)" %(repr(self.name), repr(self.network)) + return "pylast.Tag(%s, %s)" % (repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -2649,7 +2883,7 @@ class Tag(_BaseObject): artist = _extract(track, "name", 1) playcount = _number(_extract(track, "playcount")) - seq.append( TopItem(Track(artist, title, self.network), playcount) ) + seq.append(TopItem(Track(artist, title, self.network), playcount)) return seq @@ -2674,12 +2908,15 @@ class Tag(_BaseObject): seq = [] for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + seq.append((node.getAttribute("from"), node.getAttribute("to"))) return seq - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -2696,7 +2933,7 @@ class Tag(_BaseObject): return seq - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -2715,7 +2952,8 @@ class Tag(_BaseObject): name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "tag") %{'name': name} + return self.network._get_url(domain_name, "tag") % {'name': name} + class Track(_BaseObject, _Taggable): """A Last.fm track.""" @@ -2740,20 +2978,30 @@ class Track(_BaseObject, _Taggable): self.username = username def __repr__(self): - return "pylast.Track(%s, %s, %s)" %(repr(self.artist.name), repr(self.title), repr(self.network)) + return "pylast.Track(%s, %s, %s)" % ( + repr(self.artist.name), repr(self.title), repr(self.network)) @_string_output def __str__(self): return self.get_artist().get_name() + ' - ' + self.get_title() def __eq__(self, other): - return (self.get_title().lower() == other.get_title().lower()) and (self.get_artist().get_name().lower() == other.get_artist().get_name().lower()) + a = self.get_title().lower() + b = other.get_title().lower() + c = self.get_artist().get_name().lower() + d = other.get_artist().get_name().lower() + return (a == b) and (c == d) def __ne__(self, other): - return (self.get_title().lower() != other.get_title().lower()) or (self.get_artist().get_name().lower() != other.get_artist().get_name().lower()) + a = self.get_title().lower() + b = other.get_title().lower() + c = self.get_artist().get_name().lower() + d = other.get_artist().get_name().lower() + return (a != b) or (c != d) def _get_params(self): - return {'artist': self.get_artist().get_name(), 'track': self.get_title()} + return { + 'artist': self.get_artist().get_name(), 'track': self.get_title()} def get_artist(self): """Returns the associated Artist object.""" @@ -2843,7 +3091,8 @@ class Track(_BaseObject, _Taggable): """Returns True if the fulltrack is available for streaming.""" doc = self._request("track.getInfo", True) - return doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" + return doc.getElementsByTagName( + "streamable")[0].getAttribute("fulltrack") == "1" def get_album(self): """Returns the album object of this track.""" @@ -2856,7 +3105,8 @@ class Track(_BaseObject, _Taggable): return node = doc.getElementsByTagName("album")[0] - return Album(_extract(node, "artist"), _extract(node, "title"), self.network) + return Album( + _extract(node, "artist"), _extract(node, "title"), self.network) def get_wiki_published_date(self): """Returns the date of publishing this version of the wiki.""" @@ -2910,7 +3160,10 @@ class Track(_BaseObject, _Taggable): self._request('track.ban') def get_similar(self): - """Returns similar tracks for this track on the network, based on listening data. """ + """ + Returns similar tracks for this track on the network, + based on listening data. + """ doc = self._request('track.getSimilar', True) @@ -2924,7 +3177,7 @@ class Track(_BaseObject, _Taggable): return seq - def get_top_fans(self, limit = None): + def get_top_fans(self, limit=None): """Returns a list of the Users who played this track.""" doc = self._request('track.getTopFans', True) @@ -2944,13 +3197,14 @@ class Track(_BaseObject, _Taggable): return seq - def share(self, users, message = None): + def share(self, users, message=None): """Shares this track (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, or all of them. + * users: A list that can contain usernames, emails, User objects, + or all of them. * message: A message to include in the recommendation message. """ - #last.fm currently accepts a max of 10 recipient at a time + # Last.fm currently accepts a max of 10 recipient at a time while(len(users) > 10): section = users[0:9] users = users[9:] @@ -2971,7 +3225,7 @@ class Track(_BaseObject, _Taggable): self._request('track.share', False, params) - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -2991,7 +3245,9 @@ class Track(_BaseObject, _Taggable): artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) - return self.network._get_url(domain_name, "track") %{'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} + return self.network._get_url(domain_name, "track") % { + 'domain': self.network._get_language_domain(domain_name), + 'artist': artist, 'title': title} def get_shouts(self, limit=50, cacheable=False): """ @@ -3000,14 +3256,16 @@ class Track(_BaseObject, _Taggable): shouts = [] for node in _collect_nodes(limit, self, "track.getShouts", cacheable): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) + shouts.append( + Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) return shouts + class Group(_BaseObject): """A Last.fm group.""" @@ -3021,7 +3279,7 @@ class Group(_BaseObject): self.name = group_name def __repr__(self): - return "pylast.Group(%s, %s)" %(repr(self.name), repr(self.network)) + return "pylast.Group(%s, %s)" % (repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -3047,12 +3305,15 @@ class Group(_BaseObject): seq = [] for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + seq.append((node.getAttribute("from"), node.getAttribute("to"))) return seq - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3069,8 +3330,11 @@ class Group(_BaseObject): return seq - def get_weekly_album_charts(self, from_date = None, to_date = None): - """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_album_charts(self, from_date=None, to_date=None): + """ + Returns the weekly album charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3081,14 +3345,18 @@ class Group(_BaseObject): seq = [] for node in doc.getElementsByTagName("album"): - item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) + item = Album( + _extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq - def get_weekly_track_charts(self, from_date = None, to_date = None): - """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_track_charts(self, from_date=None, to_date=None): + """ + Returns the weekly track charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3099,13 +3367,14 @@ class Group(_BaseObject): seq = [] for node in doc.getElementsByTagName("track"): - item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + item = Track( + _extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -3124,7 +3393,7 @@ class Group(_BaseObject): name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "group") %{'name': name} + return self.network._get_url(domain_name, "group") % {'name': name} def get_members(self, limit=50, cacheable=False): """ @@ -3141,6 +3410,7 @@ class Group(_BaseObject): return users + class XSPF(_BaseObject): "A Last.fm XSPF playlist.""" @@ -3185,6 +3455,7 @@ class XSPF(_BaseObject): return seq + class User(_BaseObject): """A Last.fm user.""" @@ -3202,7 +3473,7 @@ class User(_BaseObject): self._recommended_artists_index = 0 def __repr__(self): - return "pylast.User(%s, %s)" %(repr(self.name), repr(self.network)) + return "pylast.User(%s, %s)" % (repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -3239,25 +3510,32 @@ class User(_BaseObject): return _extract_events_from_doc(doc, self.network) def get_artist_tracks(self, artist, cacheable=False): - """Get a list of tracks by a given artist scrobbled by this user, including scrobble time.""" - # Not implemented: "Can be limited to specific timeranges, defaults to all time." + """ + Get a list of tracks by a given artist scrobbled by this user, + including scrobble time. + """ + # Not implemented: + # "Can be limited to specific timeranges, defaults to all time." params = self._get_params() params['artist'] = artist seq = [] - for track in _collect_nodes(None, self, "user.getArtistTracks", cacheable, params): + for track in _collect_nodes( + None, self, "user.getArtistTracks", cacheable, params): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName( + "date")[0].getAttribute("uts") - seq.append(PlayedTrack(Track(artist, title, self.network), album, date, timestamp)) + seq.append(PlayedTrack( + Track(artist, title, self.network), album, date, timestamp)) return seq - def get_friends(self, limit = 50, cacheable=False): + def get_friends(self, limit=50, cacheable=False): """Returns a list of the user's friends. """ seq = [] @@ -3267,8 +3545,9 @@ class User(_BaseObject): return seq def get_loved_tracks(self, limit=50, cacheable=True): - """Returns this user's loved track as a sequence of LovedTrack objects - in reverse order of their timestamp, all the way back to the first track. + """ + Returns this user's loved track as a sequence of LovedTrack objects in + reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. @@ -3276,25 +3555,29 @@ class User(_BaseObject): large amount of data. Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ + get only a sequence of Track objects with no playback dates. + """ params = self._get_params() if limit: params['limit'] = limit seq = [] - for track in _collect_nodes(limit, self, "user.getLovedTracks", cacheable, params): + for track in _collect_nodes( + limit, self, "user.getLovedTracks", cacheable, params): title = _extract(track, "name") artist = _extract(track, "name", 1) date = _extract(track, "date") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName( + "date")[0].getAttribute("uts") - seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) + seq.append(LovedTrack( + Track(artist, title, self.network), date, timestamp)) return seq - def get_neighbours(self, limit = 50): + def get_neighbours(self, limit=50): """Returns a list of the user's friends.""" params = self._get_params() @@ -3330,12 +3613,15 @@ class User(_BaseObject): playlists = [] for playlist_id in _extract_all(doc, "id"): - playlists.append(Playlist(self.get_name(), playlist_id, self.network)) + playlists.append( + Playlist(self.get_name(), playlist_id, self.network)) return playlists def get_now_playing(self): - """Returns the currently playing track, or None if nothing is playing. """ + """ + Returns the currently playing track, or None if nothing is playing. + """ params = self._get_params() params['limit'] = '1' @@ -3357,10 +3643,10 @@ class User(_BaseObject): return Track(artist, title, self.network, self.name) - def get_recent_tracks(self, limit=10, cacheable=True): - """Returns this user's played track as a sequence of PlayedTrack objects - in reverse order of their playtime, all the way back to the first track. + """ + Returns this user's played track as a sequence of PlayedTrack objects + in reverse order of playtime, all the way back to the first track. If limit==None, it will try to pull all the available data. @@ -3368,25 +3654,29 @@ class User(_BaseObject): large amount of data. Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ + get only a sequence of Track objects with no playback dates. + """ params = self._get_params() if limit: params['limit'] = limit seq = [] - for track in _collect_nodes(limit, self, "user.getRecentTracks", cacheable, params): + for track in _collect_nodes( + limit, self, "user.getRecentTracks", cacheable, params): if track.hasAttribute('nowplaying'): - continue #to prevent the now playing track from sneaking in here + continue # to prevent the now playing track from sneaking in title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName( + "date")[0].getAttribute("uts") - seq.append(PlayedTrack(Track(artist, title, self.network), album, date, timestamp)) + seq.append(PlayedTrack( + Track(artist, title, self.network), album, date, timestamp)) return seq @@ -3463,9 +3753,10 @@ class User(_BaseObject): doc = self._request("user.getInfo", True) - return doc.getElementsByTagName("registered")[0].getAttribute("unixtime") + return doc.getElementsByTagName( + "registered")[0].getAttribute("unixtime") - def get_top_albums(self, period = PERIOD_OVERALL): + def get_top_albums(self, period=PERIOD_OVERALL): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3490,7 +3781,7 @@ class User(_BaseObject): return seq - def get_top_artists(self, period = PERIOD_OVERALL): + def get_top_artists(self, period=PERIOD_OVERALL): """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3515,7 +3806,9 @@ class User(_BaseObject): return seq def get_top_tags(self, limit=None, cacheable=True): - """Returns a sequence of the top tags used by this user with their counts as TopItem objects. + """ + Returns a sequence of the top tags used by this user with their counts + as TopItem objects. * limit: The limit of how many tags to return. * cacheable: Whether to cache results. """ @@ -3527,11 +3820,13 @@ class User(_BaseObject): seq = [] for node in doc.getElementsByTagName("tag"): - seq.append(TopItem(Tag(_extract(node, "name"), self.network), _extract(node, "count"))) + seq.append(TopItem( + Tag(_extract(node, "name"), self.network), + _extract(node, "count"))) return seq - def get_top_tracks(self, period = PERIOD_OVERALL): + def get_top_tracks(self, period=PERIOD_OVERALL): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3563,12 +3858,15 @@ class User(_BaseObject): seq = [] for node in doc.getElementsByTagName("chart"): - seq.append( (node.getAttribute("from"), node.getAttribute("to")) ) + seq.append((node.getAttribute("from"), node.getAttribute("to"))) return seq - def get_weekly_artist_charts(self, from_date = None, to_date = None): - """Returns the weekly artist charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3585,8 +3883,11 @@ class User(_BaseObject): return seq - def get_weekly_album_charts(self, from_date = None, to_date = None): - """Returns the weekly album charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_album_charts(self, from_date=None, to_date=None): + """ + Returns the weekly album charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3597,14 +3898,18 @@ class User(_BaseObject): seq = [] for node in doc.getElementsByTagName("album"): - item = Album(_extract(node, "artist"), _extract(node, "name"), self.network) + item = Album( + _extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq - def get_weekly_track_charts(self, from_date = None, to_date = None): - """Returns the weekly track charts for the week starting from the from_date value to the to_date value.""" + def get_weekly_track_charts(self, from_date=None, to_date=None): + """ + Returns the weekly track charts for the week starting from the + from_date value to the to_date value. + """ params = self._get_params() if from_date and to_date: @@ -3615,15 +3920,18 @@ class User(_BaseObject): seq = [] for node in doc.getElementsByTagName("track"): - item = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + item = Track( + _extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) return seq - def compare_with_user(self, user, shared_artists_limit = None): - """Compare this user with another Last.fm user. - Returns a sequence (tasteometer_score, (shared_artist1, shared_artist2, ...)) + def compare_with_user(self, user, shared_artists_limit=None): + """ + Compare this user with another Last.fm user. + Returns a sequence: + (tasteometer_score, (shared_artist1, shared_artist2, ...)) user: A User object or a username string/unicode object. """ @@ -3659,7 +3967,7 @@ class User(_BaseObject): return _extract(doc, "image") - def get_url(self, domain_name = DOMAIN_ENGLISH): + def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -3678,7 +3986,7 @@ class User(_BaseObject): name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "user") %{'name': name} + return self.network._get_url(domain_name, "user") % {'name': name} def get_library(self): """Returns the associated Library object. """ @@ -3687,17 +3995,18 @@ class User(_BaseObject): def get_shouts(self, limit=50, cacheable=False): """ - Returns a sequqence of Shout objects + Returns a sequence of Shout objects """ shouts = [] for node in _collect_nodes(limit, self, "user.getShouts", cacheable): - shouts.append(Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) + shouts.append( + Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) return shouts def shout(self, message): @@ -3710,9 +4019,10 @@ class User(_BaseObject): self._request("user.Shout", False, params) + class AuthenticatedUser(User): def __init__(self, network): - User.__init__(self, "", network); + User.__init__(self, "", network) def _get_params(self): return {"user": self.get_name()} @@ -3732,7 +4042,8 @@ class AuthenticatedUser(User): """ seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedEvents", cacheable): + for node in _collect_nodes( + limit, self, "user.getRecommendedEvents", cacheable): seq.append(Event(_extract(node, "id"), self.network)) return seq @@ -3744,11 +4055,13 @@ class AuthenticatedUser(User): """ seq = [] - for node in _collect_nodes(limit, self, "user.getRecommendedArtists", cacheable): + for node in _collect_nodes( + limit, self, "user.getRecommendedArtists", cacheable): seq.append(Artist(_extract(node, "name"), self.network)) return seq + class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" @@ -3788,6 +4101,7 @@ class _Search(_BaseObject): self._last_page_index += 1 return self._retrieve_page(self._last_page_index) + class AlbumSearch(_Search): """Search for an album by name.""" @@ -3802,10 +4116,14 @@ class AlbumSearch(_Search): seq = [] for node in master_node.getElementsByTagName("album"): - seq.append(Album(_extract(node, "artist"), _extract(node, "name"), self.network)) + seq.append(Album( + _extract(node, "artist"), + _extract(node, "name"), + self.network)) return seq + class ArtistSearch(_Search): """Search for an artist by artist name.""" @@ -3825,6 +4143,7 @@ class ArtistSearch(_Search): return seq + class TagSearch(_Search): """Search for a tag by tag name.""" @@ -3845,13 +4164,20 @@ class TagSearch(_Search): return seq + class TrackSearch(_Search): - """Search for a track by track title. If you don't wanna narrow the results down - by specifying the artist name, set it to empty string.""" + """ + Search for a track by track title. If you don't want to narrow the results + down by specifying the artist name, set it to empty string. + """ def __init__(self, artist_name, track_title, network): - _Search.__init__(self, "track", {"track": track_title, "artist": artist_name}, network) + _Search.__init__( + self, + "track", + {"track": track_title, "artist": artist_name}, + network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" @@ -3860,19 +4186,29 @@ class TrackSearch(_Search): seq = [] for node in master_node.getElementsByTagName("track"): - track = Track(_extract(node, "artist"), _extract(node, "name"), self.network) + track = Track( + _extract(node, "artist"), + _extract(node, "name"), + self.network) track.listener_count = _number(_extract(node, "listeners")) seq.append(track) return seq + class VenueSearch(_Search): - """Search for a venue by its name. If you don't wanna narrow the results down - by specifying a country, set it to empty string.""" + """ + Search for a venue by its name. If you don't want to narrow the results + down by specifying a country, set it to empty string. + """ def __init__(self, venue_name, country_name, network): - _Search.__init__(self, "venue", {"venue": venue_name, "country": country_name}, network) + _Search.__init__( + self, + "venue", + {"venue": venue_name, "country": country_name}, + network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" @@ -3885,13 +4221,14 @@ class VenueSearch(_Search): return seq + class Venue(_BaseObject): """A venue where events are held.""" # TODO: waiting for a venue.getInfo web service to use. - # TODO: As an intermediate use case, can pass the venue DOM element when using - # Event.get_venue() to populate the venue info, if the venue.getInfo API - # call becomes available this workaround should be removed + # TODO: As an intermediate use case, can pass the venue DOM element when + # using Event.get_venue() to populate the venue info, if the venue.getInfo + # API call becomes available this workaround should be removed id = None info = None @@ -3912,7 +4249,7 @@ class Venue(_BaseObject): self.location = self.info.get('location') def __repr__(self): - return "pylast.Venue(%s, %s)" %(repr(self.id), repr(self.network)) + return "pylast.Venue(%s, %s)" % (repr(self.id), repr(self.network)) @_string_output def __str__(self): @@ -3958,6 +4295,7 @@ class Venue(_BaseObject): return _extract_events_from_doc(doc, self.network) + def md5(text): """Returns the md5 hash of a string.""" @@ -3966,6 +4304,7 @@ def md5(text): return h.hexdigest() + def _unicode(text): if sys.version_info[0] == 3: if type(text) in (bytes, bytearray): @@ -3975,7 +4314,7 @@ def _unicode(text): else: return str(text) - elif sys.version_info[0] ==2: + elif sys.version_info[0] == 2: if type(text) in (str,): return unicode(text, "utf-8") elif type(text) == unicode: @@ -3983,6 +4322,7 @@ def _unicode(text): else: return unicode(text) + def _string(text): """For Python2 routines that can only process str type.""" @@ -4001,10 +4341,10 @@ def _string(text): return text.encode("utf-8") + def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ - Returns a sequence of dom.Node objects about as close to - limit as possible + Returns a sequence of dom.Node objects about as close to limit as possible """ if not params: @@ -4028,7 +4368,8 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): raise Exception("No total pages attribute") for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and (not limit or (len(nodes) < limit)): + if not node.nodeType == xml.dom.Node.TEXT_NODE and ( + not limit or (len(nodes) < limit)): nodes.append(node) if page >= total_pages: @@ -4038,7 +4379,8 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): return nodes -def _extract(node, name, index = 0): + +def _extract(node, name, index=0): """Extracts a value from the xml string""" nodes = node.getElementsByTagName(name) @@ -4049,7 +4391,8 @@ def _extract(node, name, index = 0): else: return None -def _extract_element_tree(node, index = 0): + +def _extract_element_tree(node, index=0): """Extract an element tree into a multi-level dictionary NB: If any elements have text nodes as well as nested @@ -4072,13 +4415,15 @@ def _extract_element_tree(node, index = 0): targetDict[node.tagName] = {} _recurse_build_tree(node, targetDict[node.tagName]) else: - val = None if node.firstChild is None else _unescape_htmlentity(node.firstChild.data.strip()) + val = None if node.firstChild is None else \ + _unescape_htmlentity(node.firstChild.data.strip()) targetDict[node.tagName] = val return targetDict return _recurse_build_tree(node, {}) -def _extract_all(node, name, limit_count = None): + +def _extract_all(node, name, limit_count=None): """Extracts all the values from the xml string. returning a list.""" seq = [] @@ -4091,20 +4436,24 @@ def _extract_all(node, name, limit_count = None): return seq + def _extract_events_from_doc(doc, network): events = [] for node in doc.getElementsByTagName("event"): events.append(Event(_extract(node, "id"), network)) return events + def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a url.""" return url_quote_plus(url_quote_plus(_string(text))).lower() + def _number(string): """ - Extracts an int from a string. Returns a 0 if None or an empty string was passed + Extracts an int from a string. + Returns a 0 if None or an empty string was passed. """ if not string: @@ -4117,18 +4466,23 @@ def _number(string): except ValueError: return float(string) + def _unescape_htmlentity(string): #string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: - string = string.replace("&%s;" %key, unichr(mapping[key])) + string = string.replace("&%s;" % key, unichr(mapping[key])) return string + def extract_items(topitems_or_libraryitems): - """Extracts a sequence of items from a sequence of TopItem or LibraryItem objects.""" + """ + Extracts a sequence of items from a sequence of TopItem or + LibraryItem objects. + """ seq = [] for i in topitems_or_libraryitems: @@ -4136,6 +4490,7 @@ def extract_items(topitems_or_libraryitems): return seq + class ScrobblingError(Exception): def __init__(self, message): Exception.__init__(self) @@ -4145,21 +4500,29 @@ class ScrobblingError(Exception): def __str__(self): return self.message + class BannedClientError(ScrobblingError): def __init__(self): - ScrobblingError.__init__(self, "This version of the client has been banned") + ScrobblingError.__init__( + self, "This version of the client has been banned") + class BadAuthenticationError(ScrobblingError): def __init__(self): ScrobblingError.__init__(self, "Bad authentication token") + class BadTimeError(ScrobblingError): def __init__(self): - ScrobblingError.__init__(self, "Time provided is not close enough to current time") + ScrobblingError.__init__( + self, "Time provided is not close enough to current time") + class BadSessionError(ScrobblingError): def __init__(self): - ScrobblingError.__init__(self, "Bad session id, consider re-handshaking") + ScrobblingError.__init__( + self, "Bad session id, consider re-handshaking") + class _ScrobblerRequest(object): @@ -4189,10 +4552,11 @@ class _ScrobblerRequest(object): "Accept-Charset": "utf-8", "User-Agent": "pylast" + "/" + __version__, "HOST": self.hostname - } + } if self.type == "GET": - connection.request("GET", self.subdir + "?" + data, headers = headers) + connection.request( + "GET", self.subdir + "?" + data, headers=headers) else: connection.request("POST", self.subdir, data, headers) response = _unicode(connection.getresponse().read()) @@ -4202,8 +4566,10 @@ class _ScrobblerRequest(object): return response def _check_response_for_errors(self, response): - """When passed a string response it checks for erros, raising - any exceptions as necessary.""" + """ + When passed a string response it checks for errors, raising any + exceptions as necessary. + """ lines = response.split("\n") status_line = lines[0] @@ -4219,9 +4585,10 @@ class _ScrobblerRequest(object): elif status_line == "BADSESSION": raise BadSessionError() elif status_line.startswith("FAILED "): - reason = status_line[status_line.find("FAILED ")+len("FAILED "):] + reason = status_line[status_line.find("FAILED ") + len("FAILED "):] raise ScrobblingError(reason) + class Scrobbler(object): """A class for scrobbling tracks to Last.fm""" @@ -4243,12 +4610,15 @@ class Scrobbler(object): if self.password and self.username: token = md5(self.password + timestamp) - elif self.network.api_key and self.network.api_secret and self.network.session_key: + elif self.network.api_key and self.network.api_secret and \ + self.network.session_key: if not self.username: - self.username = self.network.get_authenticated_user().get_name() + self.username = self.network.get_authenticated_user()\ + .get_name() token = md5(self.network.api_secret + timestamp) - params = {"hs": "true", "p": "1.2.1", "c": self.client_id, + params = { + "hs": "true", "p": "1.2.1", "c": self.client_id, "v": self.client_version, "u": self.username, "t": timestamp, "a": token} @@ -4257,61 +4627,91 @@ class Scrobbler(object): params["api_key"] = self.network.api_key server = self.network.submission_server - response = _ScrobblerRequest(server, params, self.network, "GET").execute().split("\n") + response = _ScrobblerRequest( + server, params, self.network, "GET").execute().split("\n") self.session_id = response[1] self.nowplaying_url = response[2] self.submissions_url = response[3] - def _get_session_id(self, new = False): - """Returns a handshake. If new is true, then it will be requested from the server - even if one was cached.""" + def _get_session_id(self, new=False): + """ + Returns a handshake. If new is true, then it will be requested from + the server even if one was cached. + """ if not self.session_id or new: self._do_handshake() return self.session_id - def report_now_playing(self, artist, title, album = "", duration = "", track_number = "", mbid = ""): + def report_now_playing( + self, artist, title, album="", duration="", track_number="", + mbid=""): - _deprecation_warning("DeprecationWarning: Use Network.update_now_playing(...) instead") + _deprecation_warning( + "DeprecationWarning: Use Network.update_now_playing(...) instead") - params = {"s": self._get_session_id(), "a": artist, "t": title, + params = { + "s": self._get_session_id(), "a": artist, "t": title, "b": album, "l": duration, "n": track_number, "m": mbid} try: - _ScrobblerRequest(self.nowplaying_url, params, self.network).execute() + _ScrobblerRequest( + self.nowplaying_url, params, self.network + ).execute() except BadSessionError: self._do_handshake() - self.report_now_playing(artist, title, album, duration, track_number, mbid) + self.report_now_playing( + artist, title, album, duration, track_number, mbid) - def scrobble(self, artist, title, time_started, source, mode, duration, album="", track_number="", mbid=""): + def scrobble( + self, artist, title, time_started, source, mode, duration, + album="", track_number="", mbid=""): """Scrobble a track. parameters: artist: Artist name. title: Track title. time_started: UTC timestamp of when the track started playing. source: The source of the track - SCROBBLE_SOURCE_USER: Chosen by the user (the most common value, unless you have a reason for choosing otherwise, use this). - SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1). - SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised recommendation except Last.fm (e.g. Pandora, Launchcast). - SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the 5-digit recommendation_key value must be set. + SCROBBLE_SOURCE_USER: Chosen by the user + (the most common value, unless you have a reason for + choosing otherwise, use this). + SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised + broadcast (e.g. Shoutcast, BBC Radio 1). + SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised + recommendation except Last.fm (e.g. Pandora, Launchcast). + SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the + 5-digit recommendation_key value must be set. SCROBBLE_SOURCE_UNKNOWN: Source unknown. mode: The submission mode SCROBBLE_MODE_PLAYED: The track was played. - SCROBBLE_MODE_LOVED: The user manually loved the track (implies a listen) - SCROBBLE_MODE_SKIPPED: The track was skipped (Only if source was Last.fm) - SCROBBLE_MODE_BANNED: The track was banned (Only if source was Last.fm) + SCROBBLE_MODE_LOVED: The user manually loved the track + (implies a listen) + SCROBBLE_MODE_SKIPPED: The track was skipped + (Only if source was Last.fm) + SCROBBLE_MODE_BANNED: The track was banned + (Only if source was Last.fm) duration: Track duration in seconds. album: The album name. track_number: The track number on the album. mbid: MusicBrainz ID. """ - _deprecation_warning("DeprecationWarning: Use Network.scrobble(...) instead") + _deprecation_warning( + "DeprecationWarning: Use Network.scrobble(...) instead") - params = {"s": self._get_session_id(), "a[0]": _string(artist), "t[0]": _string(title), - "i[0]": str(time_started), "o[0]": source, "r[0]": mode, "l[0]": str(duration), - "b[0]": _string(album), "n[0]": track_number, "m[0]": mbid} + params = { + "s": self._get_session_id(), + "a[0]": _string(artist), + "t[0]": _string(title), + "i[0]": str(time_started), + "o[0]": source, + "r[0]": mode, + "l[0]": str(duration), + "b[0]": _string(album), + "n[0]": track_number, + "m[0]": mbid + } _ScrobblerRequest(self.submissions_url, params, self.network).execute() @@ -4319,11 +4719,13 @@ class Scrobbler(object): """ Scrobble several tracks at once. - tracks: A sequence of a sequence of parameters for each trach. The order of parameters - is the same as if passed to the scrobble() method. + tracks: A sequence of a sequence of parameters for each track. + The order of parameters is the same as if passed to the + scrobble() method. """ - _deprecation_warning("DeprecationWarning: Use Network.scrobble_many(...) instead") + _deprecation_warning( + "DeprecationWarning: Use Network.scrobble_many(...) instead") remainder = [] diff --git a/test_pylast.py b/test_pylast.py index c7bb82c..ef6f4e7 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -9,11 +9,12 @@ import unittest import pylast + def load_secrets(): secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): - import yaml # pip install pyyaml - with open(secrets_file, "r") as f: # see example_test_pylast.yaml + import yaml # pip install pyyaml + with open(secrets_file, "r") as f: # see example_test_pylast.yaml doc = yaml.load(f) else: doc = {} @@ -38,12 +39,12 @@ class TestPyLast(unittest.TestCase): self.username = self.__class__.secrets["username"] password_hash = self.__class__.secrets["password_hash"] - API_KEY = self.__class__.secrets["api_key"] - API_SECRET = self.__class__.secrets["api_secret"] - - self.network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = - API_SECRET, username = self.username, password_hash = password_hash) + API_KEY = self.__class__.secrets["api_key"] + API_SECRET = self.__class__.secrets["api_secret"] + self.network = pylast.LastFMNetwork( + api_key=API_KEY, api_secret=API_SECRET, + username=self.username, password_hash=password_hash) def test_scrobble(self): # Arrange @@ -53,35 +54,36 @@ class TestPyLast(unittest.TestCase): lastfm_user = self.network.get_user(self.username) # Act - self.network.scrobble(artist = artist, title = title, timestamp = timestamp) + self.network.scrobble(artist=artist, title=title, timestamp=timestamp) # Assert - last_scrobble = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing + # limit=2 to ignore now-playing: + last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertEqual(str(last_scrobble.track.artist), str(artist)) self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - def test_unscrobble(self): # Arrange artist = "Test Artist 2" title = "Test Title 2" timestamp = self.unix_timestamp() - library = pylast.Library(user = self.username, network = self.network) - self.network.scrobble(artist = artist, title = title, timestamp = timestamp) + library = pylast.Library(user=self.username, network=self.network) + self.network.scrobble(artist=artist, title=title, timestamp=timestamp) lastfm_user = self.network.get_user(self.username) # Act - library.remove_scrobble(artist = artist, title = title, timestamp = timestamp) + library.remove_scrobble( + artist=artist, title=title, timestamp=timestamp) # Assert - last_scrobble = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing + # limit=2 to ignore now-playing: + last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) - def test_add_album(self): # Arrange - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) album = self.network.get_album("Test Artist", "Test Album") # Act @@ -95,12 +97,11 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) - def test_remove_album(self): # Arrange - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) # Pick an artist with plenty of albums - artist = self.network.get_top_artists(limit = 1)[0].item + artist = self.network.get_top_artists(limit=1)[0].item albums = artist.get_top_albums() # Pick a random one to avoid problems running concurrent tests album = choice(albums)[0] @@ -117,11 +118,10 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) - def test_add_artist(self): # Arrange artist = "Test Artist 2" - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) # Act library.add_artist(artist) @@ -134,14 +134,13 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) - def test_remove_artist(self): # Arrange # Get plenty of artists artists = self.network.get_top_artists() # Pick a random one to avoid problems running concurrent tests my_artist = choice(artists).item - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) library.add_artist(my_artist) # Act @@ -155,7 +154,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) - def test_get_venue(self): # Arrange venue_name = "Last.fm Office" @@ -168,7 +166,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(str(venue.id), "8778225") - def test_get_user_registration(self): # Arrange username = "RJ" @@ -181,7 +178,6 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertIn(u"2002-11-20 ", registered) - def test_get_user_unixtime_registration(self): # Arrange username = "RJ" @@ -194,10 +190,10 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertEqual(unixtime_registered, u"1037793040") - def test_get_genderless_user(self): # Arrange - lastfm_user = self.network.get_user("test_user") # currently no gender set + # Currently test_user has no gender set: + lastfm_user = self.network.get_user("test_user") # Act gender = lastfm_user.get_gender() @@ -205,10 +201,10 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(gender) - def test_get_countryless_user(self): # Arrange - lastfm_user = self.network.get_user("test_user") # currently no country set + # Currently test_user has no country set: + lastfm_user = self.network.get_user("test_user") # Act country = lastfm_user.get_country() @@ -216,7 +212,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(country) - def test_love(self): # Arrange artist = "Test Artist" @@ -228,11 +223,10 @@ class TestPyLast(unittest.TestCase): track.love() # Assert - loved = lastfm_user.get_loved_tracks(limit = 1) + loved = lastfm_user.get_loved_tracks(limit=1) self.assertEqual(str(loved[0].track.artist), "Test Artist") self.assertEqual(str(loved[0].track.title), "Test Title") - def test_unlove(self): # Arrange artist = "Test Artist" @@ -245,59 +239,55 @@ class TestPyLast(unittest.TestCase): track.unlove() # Assert - loved = lastfm_user.get_loved_tracks(limit = 1) - if len(loved): # OK to be empty but if not: + loved = lastfm_user.get_loved_tracks(limit=1) + if len(loved): # OK to be empty but if not: self.assertNotEqual(str(loved.track.artist), "Test Artist") self.assertNotEqual(str(loved.track.title), "Test Title") - def test_get_100_albums(self): # Arrange - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) # Act - albums = library.get_albums(limit = 100) + albums = library.get_albums(limit=100) # Assert self.assertGreaterEqual(len(albums), 0) - def test_get_limitless_albums(self): # Arrange - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) # Act - albums = library.get_albums(limit = None) + albums = library.get_albums(limit=None) # Assert self.assertGreaterEqual(len(albums), 0) - def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) # Act - value = (lastfm_user == None) + value = (lastfm_user is None) # Assert self.assertFalse(value) - def test_user_not_equal_to_none(self): # Arrange lastfm_user = self.network.get_user(self.username) # Act - value = (lastfm_user != None) + value = (lastfm_user is not None) # Assert self.assertTrue(value) - def test_now_playing_user_with_no_scrobbles(self): # Arrange - user = self.network.get_user('test-account') # currently has no scrobbles + # Currently test-account has no scrobbles: + user = self.network.get_user('test-account') # Act current_track = user.get_now_playing() @@ -305,10 +295,10 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(current_track) - def test_love_limits(self): # Arrange - user = self.network.get_user("test-user") # currently at least 23 loved tracks + # Currently test-account has at least 23 loved tracks: + user = self.network.get_user("test-user") # Act/Assert self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) @@ -316,7 +306,6 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) - def test_update_now_playing(self): # Arrange artist = "Test Artist" @@ -326,7 +315,8 @@ class TestPyLast(unittest.TestCase): lastfm_user = self.network.get_user(self.username) # Act - self.network.update_now_playing(artist = artist, title = title, album = album, track_number = track_number) + self.network.update_now_playing( + artist=artist, title=title, album=album, track_number=track_number) # Assert current_track = lastfm_user.get_now_playing() @@ -334,33 +324,31 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.title), "Test Title") self.assertEqual(str(current_track.artist), "Test Artist") - def test_libre_fm(self): # Arrange - username = self.__class__.secrets["username"] + username = self.__class__.secrets["username"] password_hash = self.__class__.secrets["password_hash"] # Act - network = pylast.LibreFMNetwork(password_hash = password_hash, username = username) - tags = network.get_top_tags(limit = 1) + network = pylast.LibreFMNetwork( + password_hash=password_hash, username=username) + tags = network.get_top_tags(limit=1) # Assert self.assertGreater(len(tags), 0) self.assertEqual(type(tags[0]), pylast.TopItem) - def test_album_tags_are_topitems(self): # Arrange albums = self.network.get_user('RJ').get_top_albums() # Act - tags = albums[0].item.get_top_tags(limit = 1) + tags = albums[0].item.get_top_tags(limit=1) # Assert self.assertGreater(len(tags), 0) self.assertEqual(type(tags[0]), pylast.TopItem) - def helper_is_thing_hashable(self, thing): # Arrange things = set() @@ -379,7 +367,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(album) - def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") @@ -389,7 +376,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(artist) - def test_country_is_hashable(self): # Arrange country = self.network.get_country("Italy") @@ -397,24 +383,21 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(country) - - def test_country_is_hashable(self): + def test_metro_is_hashable(self): # Arrange metro = self.network.get_metro("Helsinki", "Finland") # Act/Assert self.helper_is_thing_hashable(metro) - def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") - event = user.get_past_events(limit = 1)[0] + event = user.get_past_events(limit=1)[0] # Act/Assert self.helper_is_thing_hashable(event) - def test_group_is_hashable(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -422,31 +405,28 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(group) - def test_library_is_hashable(self): # Arrange - library = pylast.Library(user = self.username, network = self.network) + library = pylast.Library(user=self.username, network=self.network) # Act/Assert self.helper_is_thing_hashable(library) - def test_playlist_is_hashable(self): # Arrange - playlist = pylast.Playlist(user = "RJ", id = "1k1qp_doglist", network = self.network) + playlist = pylast.Playlist( + user="RJ", id="1k1qp_doglist", network=self.network) # Act/Assert self.helper_is_thing_hashable(playlist) - def test_tag_is_hashable(self): # Arrange - tag = self.network.get_top_tags(limit = 1)[0] + tag = self.network.get_top_tags(limit=1)[0] # Act/Assert self.helper_is_thing_hashable(tag) - def test_track_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -456,7 +436,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(track) - def test_user_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -466,24 +445,22 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(user) - def test_venue_is_hashable(self): # Arrange - venue_id = "8778225" # Last.fm office + venue_id = "8778225" # Last.fm office venue = pylast.Venue(venue_id, self.network) # Act/Assert self.helper_is_thing_hashable(venue) - def test_xspf_is_hashable(self): # Arrange - xspf = pylast.XSPF(uri = "lastfm://playlist/1k1qp_doglist", network = self.network) + xspf = pylast.XSPF( + uri="lastfm://playlist/1k1qp_doglist", network=self.network) # Act/Assert self.helper_is_thing_hashable(xspf) - def test_invalid_xml(self): # Arrange # Currently causes PCDATA invalid Char value 25 @@ -497,12 +474,13 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(int(total), 0) - def test_user_play_count_in_track_info(self): # Arrange artist = "Test Artist" title = "Test Title" - track = pylast.Track(artist = artist, title = title, network = self.network, username = self.username) + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) # Act count = track.get_userplaycount() @@ -510,12 +488,13 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(count, 0) - def test_user_loved_in_track_info(self): # Arrange artist = "Test Artist" title = "Test Title" - track = pylast.Track(artist = artist, title = title, network = self.network, username = self.username) + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) # Act loved = track.get_userloved() @@ -525,29 +504,27 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(loved, bool) self.assertNotIsInstance(loved, str) - def test_album_in_recent_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) # Act - track = lastfm_user.get_recent_tracks(limit = 2)[0] # 2 to ignore now-playing + # limit=2 to ignore now-playing: + track = lastfm_user.get_recent_tracks(limit=2)[0] # Assert self.assertTrue(hasattr(track, 'album')) - def test_album_in_artist_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) # Act - track = lastfm_user.get_artist_tracks(artist = "Test Artist")[0] + track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] # Assert self.assertTrue(hasattr(track, 'album')) - def test_enable_rate_limiting(self): # Arrange self.assertFalse(self.network.is_rate_limited()) @@ -565,7 +542,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(self.network.is_rate_limited()) self.assertGreaterEqual(now - then, 0.2) - def test_disable_rate_limiting(self): # Arrange self.network.enable_rate_limit() @@ -573,32 +549,28 @@ class TestPyLast(unittest.TestCase): # Act self.network.disable_rate_limit() - then = time.time() # Make some network call, limit not applied first time self.network.get_user(self.username) # Make a second network call, limiting should be applied self.network.get_top_artists() - now = time.time() # Assert self.assertFalse(self.network.is_rate_limited()) - - # Commented out because (a) it'll take a long time and - # (b) it strangely fails due Last.fm's complaining of hitting the rate limit, - # even when limited to one call per second. The ToS allows 5 calls per second. + # Commented out because (a) it'll take a long time and (b) it strangely + # fails due Last.fm's complaining of hitting the rate limit, even when + # limited to one call per second. The ToS allows 5 calls per second. # def test_get_all_scrobbles(self): # # Arrange # lastfm_user = self.network.get_user("RJ") # self.network.enable_rate_limit() # this is going to be slow... # # Act - # tracks = lastfm_user.get_recent_tracks(limit = None) + # tracks = lastfm_user.get_recent_tracks(limit=None) # # Assert # self.assertGreaterEqual(len(tracks), 0) - def helper_past_events_have_valid_ids(self, thing): # Act events = thing.get_past_events() @@ -606,7 +578,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_assert_events_have_valid_ids(events) - def helper_upcoming_events_have_valid_ids(self, thing): # Act events = thing.get_upcoming_events() @@ -614,14 +585,13 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_assert_events_have_valid_ids(events) - def helper_assert_events_have_valid_ids(self, events): # Assert - self.assertGreaterEqual(len(events), 1) # if fails, add past/future event for user/Test Artist - for event in events[:2]: # checking first two should be enough + # If fails, add past/future event for user/Test Artist: + self.assertGreaterEqual(len(events), 1) + for event in events[:2]: # checking first two should be enough self.assertIsInstance(event.get_headliner(), pylast.Artist) - def test_artist_upcoming_events_returns_valid_ids(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -629,7 +599,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(artist) - def test_user_past_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -637,7 +606,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_past_events_have_valid_ids(lastfm_user) - def test_user_recommended_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -648,7 +616,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_assert_events_have_valid_ids(events) - def test_user_upcoming_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -656,25 +623,22 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(lastfm_user) - def test_venue_past_events_returns_valid_ids(self): # Arrange - venue_id = "8778225" # Last.fm office + venue_id = "8778225" # Last.fm office venue = pylast.Venue(venue_id, self.network) # Act/Assert self.helper_past_events_have_valid_ids(venue) - def test_venue_upcoming_events_returns_valid_ids(self): # Arrange - venue_id = "8778225" # Last.fm office + venue_id = "8778225" # Last.fm office venue = pylast.Venue(venue_id, self.network) # Act/Assert self.helper_upcoming_events_have_valid_ids(venue) - def test_pickle(self): # Arrange import pickle @@ -689,31 +653,28 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(lastfm_user, loaded_user) - def test_bio_content(self): # Arrange artist = pylast.Artist("Test Artist", self.network) # Act - bio = artist.get_bio_content(language = "en") + bio = artist.get_bio_content(language="en") # Assert self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) - def test_bio_summary(self): # Arrange artist = pylast.Artist("Test Artist", self.network) # Act - bio = artist.get_bio_summary(language = "en") + bio = artist.get_bio_summary(language="en") # Assert self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) - def test_album_wiki_content(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -725,7 +686,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_album_wiki_summary(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -737,7 +697,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_track_wiki_content(self): # Arrange track = pylast.Track("Test Artist", "Test Title", self.network) @@ -749,7 +708,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_track_wiki_summary(self): # Arrange track = pylast.Track("Test Artist", "Test Title", self.network) @@ -761,7 +719,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_lastfm_network_name(self): # Act name = str(self.network) @@ -769,7 +726,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(name, "Last.fm Network") - def test_artist_get_images_deprecated(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -778,7 +734,6 @@ class TestPyLast(unittest.TestCase): with self.assertRaisesRegexp(pylast.WSError, 'deprecated'): artist.get_images() - def helper_validate_results(self, a, b, c): # Assert self.assertIsNotNone(a) @@ -790,21 +745,19 @@ class TestPyLast(unittest.TestCase): self.assertEqual(a, b) self.assertEqual(b, c) - def helper_validate_cacheable(self, thing, function_name): # Arrange # get thing.function_name() func = getattr(thing, function_name, None) # Act - result1 = func(limit = 1, cacheable = False) - result2 = func(limit = 1, cacheable = True) - result3 = func(limit = 1) + result1 = func(limit=1, cacheable=False) + result2 = func(limit=1, cacheable=True) + result3 = func(limit=1) # Assert self.helper_validate_results(result1, result2, result3) - def test_cacheable_artist_get_shouts(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -812,16 +765,14 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(artist, "get_shouts") - def test_cacheable_event_get_shouts(self): # Arrange user = self.network.get_user("RJ") - event = user.get_past_events(limit = 1)[0] + event = user.get_past_events(limit=1)[0] # Act/Assert self.helper_validate_cacheable(event, "get_shouts") - def test_cacheable_track_get_shouts(self): # Arrange track = self.network.get_top_tracks()[0].item @@ -829,7 +780,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(track, "get_shouts") - def test_cacheable_group_get_members(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -837,7 +787,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(group, "get_members") - def test_cacheable_library(self): # Arrange library = pylast.Library(self.username, self.network) @@ -847,20 +796,18 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(library, "get_artists") self.helper_validate_cacheable(library, "get_tracks") - def test_cacheable_user_artist_tracks(self): # Arrange lastfm_user = self.network.get_authenticated_user() # Act - result1 = lastfm_user.get_artist_tracks(artist = "Test Artist", cacheable = False) - result2 = lastfm_user.get_artist_tracks(artist = "Test Artist", cacheable = True) - result3 = lastfm_user.get_artist_tracks(artist = "Test Artist") + result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) + result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) + result3 = lastfm_user.get_artist_tracks("Test Artist") # Assert self.helper_validate_results(result1, result2, result3) - def test_cacheable_user(self): # Arrange lastfm_user = self.network.get_authenticated_user() @@ -874,11 +821,11 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_recommended_events") self.helper_validate_cacheable(lastfm_user, "get_shouts") - def test_geo_get_events_in_location(self): # Arrange # Act - events = self.network.get_geo_events(location = "London", tag = "blues", limit = 1) + events = self.network.get_geo_events( + location="London", tag="blues", limit=1) # Assert self.assertEqual(len(events), 1) @@ -886,11 +833,11 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(event), pylast.Event) self.assertEqual(event.get_venue().location['city'], "London") - def test_geo_get_events_in_latlong(self): # Arrange # Act - events = self.network.get_geo_events(lat = 40.67, long = -73.94, distance = 5, limit = 1) + events = self.network.get_geo_events( + lat=40.67, long=-73.94, distance=5, limit=1) # Assert self.assertEqual(len(events), 1) @@ -898,11 +845,11 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(event), pylast.Event) self.assertEqual(event.get_venue().location['city'], "New York") - def test_geo_get_events_festival(self): # Arrange # Act - events = self.network.get_geo_events(location = "Reading", festivalsonly = True, limit = 1) + events = self.network.get_geo_events( + location="Reading", festivalsonly=True, limit=1) # Assert self.assertEqual(len(events), 1) @@ -910,7 +857,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(event), pylast.Event) self.assertEqual(event.get_venue().location['city'], "Reading") - def test_get_metro_weekly_chart_dates(self): # Arrange # Act @@ -922,8 +868,7 @@ class TestPyLast(unittest.TestCase): (start, end) = dates[0] self.assertLess(start, end) - - def helper_geo_chart(self, function_name, expected_type = pylast.Artist): + def helper_geo_chart(self, function_name, expected_type=pylast.Artist): # Arrange metro = self.network.get_metro("Madrid", "Spain") dates = self.network.get_metro_weekly_chart_dates() @@ -933,77 +878,71 @@ class TestPyLast(unittest.TestCase): func = getattr(metro, function_name, None) # Act - chart = func(from_date = from_date, to_date = to_date, limit = 1) + chart = func(from_date=from_date, to_date=to_date, limit=1) # Assert self.assertEqual(len(chart), 1) self.assertEqual(type(chart[0]), pylast.TopItem) self.assertEqual(type(chart[0].item), expected_type) - def test_get_metro_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_artist_chart") - def test_get_metro_hype_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_hype_artist_chart") - def test_get_metro_unique_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_unique_artist_chart") - def test_get_metro_track_chart(self): # Arrange/Act/Assert - self.helper_geo_chart("get_track_chart", expected_type = pylast.Track) - + self.helper_geo_chart("get_track_chart", expected_type=pylast.Track) def test_get_metro_hype_track_chart(self): # Arrange/Act/Assert - self.helper_geo_chart("get_hype_track_chart", expected_type = pylast.Track) - + self.helper_geo_chart( + "get_hype_track_chart", expected_type=pylast.Track) def test_get_metro_unique_track_chart(self): # Arrange/Act/Assert - self.helper_geo_chart("get_unique_track_chart", expected_type = pylast.Track) - + self.helper_geo_chart( + "get_unique_track_chart", expected_type=pylast.Track) def test_geo_get_metros(self): # Arrange # Act - metros = self.network.get_metros(country = "Poland") + metros = self.network.get_metros(country="Poland") # Assert self.assertGreaterEqual(len(metros), 1) self.assertEqual(type(metros[0]), pylast.Metro) self.assertEqual(metros[0].get_country(), "Poland") - def test_geo_get_top_artists(self): # Arrange # Act - artists = self.network.get_geo_top_artists(country = "United Kingdom", limit = 1) + artists = self.network.get_geo_top_artists( + country="United Kingdom", limit=1) # Assert self.assertEqual(len(artists), 1) self.assertEqual(type(artists[0]), pylast.TopItem) self.assertEqual(type(artists[0].item), pylast.Artist) - def test_geo_get_top_tracks(self): # Arrange # Act - tracks = self.network.get_geo_top_tracks(country = "United Kingdom", location = "Manchester", limit = 1) + tracks = self.network.get_geo_top_tracks( + country="United Kingdom", location="Manchester", limit=1) # Assert self.assertEqual(len(tracks), 1) self.assertEqual(type(tracks[0]), pylast.TopItem) self.assertEqual(type(tracks[0].item), pylast.Track) - def test_metro_class(self): # Arrange # Act @@ -1014,13 +953,14 @@ class TestPyLast(unittest.TestCase): self.assertEqual(metro.get_country(), "Norway") self.assertEqual(str(metro), "Bergen, Norway") self.assertEqual(metro, pylast.Metro("Bergen", "Norway", self.network)) - self.assertNotEqual(metro, pylast.Metro("Wellington", "New Zealand", self.network)) - + self.assertNotEqual( + metro, + pylast.Metro("Wellington", "New Zealand", self.network)) def test_get_album_play_links(self): # Arrange - album1 = self.network.get_album(artist = "Portishead", title = "Dummy") - album2 = self.network.get_album(artist = "Radiohead", title = "OK Computer") + album1 = self.network.get_album("Portishead", "Dummy") + album2 = self.network.get_album("Radiohead", "OK Computer") albums = [album1, album2] # Act @@ -1032,7 +972,6 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:album:", links[0]) self.assertIn("spotify:album:", links[1]) - def test_get_artist_play_links(self): # Arrange artists = ["Portishead", "Radiohead"] @@ -1045,11 +984,10 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:artist:", links[0]) self.assertIn("spotify:artist:", links[1]) - def test_get_track_play_links(self): # Arrange - track1 = self.network.get_track(artist = "Portishead", title = "Mysterons") - track2 = self.network.get_track(artist = "Radiohead", title = "Creep") + track1 = self.network.get_track(artist="Portishead", title="Mysterons") + track2 = self.network.get_track(artist="Radiohead", title="Creep") tracks = [track1, track2] # Act @@ -1061,7 +999,6 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:track:", links[0]) self.assertIn("spotify:track:", links[1]) - def helper_only_one_thing_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 1) @@ -1074,34 +1011,31 @@ class TestPyLast(unittest.TestCase): user = self.network.get_user("RJ") # Act - tags = user.get_top_tags(limit = 1) + tags = user.get_top_tags(limit=1) # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - def test_network_get_top_artists_with_limit(self): # Arrange # Act - artists = self.network.get_top_artists(limit = 1) + artists = self.network.get_top_artists(limit=1) # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - def test_network_get_top_tags_with_limit(self): # Arrange # Act - tags = self.network.get_top_tags(limit = 1) + tags = self.network.get_top_tags(limit=1) # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - def test_network_get_top_tracks_with_limit(self): # Arrange # Act - tracks = self.network.get_top_tracks(limit = 1) + tracks = self.network.get_top_tracks(limit=1) # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) From 6301a5c67002fb5c05e074ae442c6ed77e7d7c11 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 11:42:20 +0200 Subject: [PATCH 100/768] Add _get_things to remove duplication --- .gitignore | 1 + pylast.py | 116 ++++++++++++++++++++----------------------------- test_pylast.py | 82 ++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 72eb345..66d3a64 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +output.html # Translations *.mo diff --git a/pylast.py b/pylast.py index 595f617..0aa9fa7 100644 --- a/pylast.py +++ b/pylast.py @@ -522,7 +522,7 @@ class _Network(object): params = {"country": country} if location: params["location"] = location - if limit: params["limit"] = limit + if limit: params['limit'] = limit doc = _Request(self, "geo.getTopTracks", params).execute(cacheable) @@ -1338,6 +1338,22 @@ class _BaseObject(object): return doc.getElementsByTagName( tag_name)[0].firstChild.wholeText.strip() + def _get_things( + self, method, thing, thing_type, params = None, cacheable=True): + """Returns a list of the most played thing_types by this thing.""" + + doc = self._request(method, cacheable, params) + + seq = [] + for node in doc.getElementsByTagName(thing): + title = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _number(_extract(node, "playcount")) + + seq.append(TopItem( + thing_type(artist, title, self.network), playcount)) + + return seq class _Taggable(object): """Common functions for classes with tags.""" @@ -1860,37 +1876,23 @@ class Artist(_BaseObject, _Taggable): return artists - def get_top_albums(self): - """Retuns a list of the top albums.""" + def get_top_albums(self, limit=None, cacheable=True): + """Returns a list of the top albums.""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request('artist.getTopAlbums', True) + return self._get_things( + "artist.getTopAlbums", "album", Album, params, cacheable) - seq = [] - - for node in doc.getElementsByTagName("album"): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _extract(node, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq - - def get_top_tracks(self): + def get_top_tracks(self, limit=None, cacheable=True): """Returns a list of the most played Tracks by this artist.""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request("artist.getTopTracks", True) - - seq = [] - for track in doc.getElementsByTagName('track'): - - title = _extract(track, "name") - artist = _extract(track, "name", 1) - playcount = _number(_extract(track, "playcount")) - - seq.append(TopItem(Track(artist, title, self.network), playcount)) - - return seq + return self._get_things( + "artist.getTopTracks", "track", Track, params, cacheable) def get_top_fans(self, limit=None): """Returns a list of the Users who played this artist the most. @@ -2293,22 +2295,14 @@ class Country(_BaseObject): return seq - def get_top_tracks(self): + def get_top_tracks(self, limit=None, cacheable=True): """Returns a sequence of the most played tracks""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request("geo.getTopTracks", True) - - seq = [] - - for n in doc.getElementsByTagName('track'): - - title = _extract(n, 'name') - artist = _extract(n, 'name', 1) - playcount = _number(_extract(n, "playcount")) - - seq.append(TopItem(Track(artist, title, self.network), playcount)) - - return seq + return self._get_things( + "geo.getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the event page on the network. @@ -2871,21 +2865,14 @@ class Tag(_BaseObject): return seq - def get_top_tracks(self): - """Returns a list of the most played Tracks by this artist.""" + def get_top_tracks(self, limit=None, cacheable=True): + """Returns a list of the most played Tracks for this tag.""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request("tag.getTopTracks", True) - - seq = [] - for track in doc.getElementsByTagName('track'): - - title = _extract(track, "name") - artist = _extract(track, "name", 1) - playcount = _number(_extract(track, "playcount")) - - seq.append(TopItem(Track(artist, title, self.network), playcount)) - - return seq + return self._get_things( + "tag.getTopTracks", "track", Track, params, cacheable) def get_top_artists(self): """Returns a sequence of the most played artists.""" @@ -3826,7 +3813,7 @@ class User(_BaseObject): return seq - def get_top_tracks(self, period=PERIOD_OVERALL): + def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3838,18 +3825,11 @@ class User(_BaseObject): params = self._get_params() params['period'] = period + if limit: + params['limit'] = limit - doc = self._request('user.getTopTracks', True, params) - - seq = [] - for track in doc.getElementsByTagName('track'): - name = _extract(track, 'name') - artist = _extract(track, 'name', 1) - playcount = _extract(track, "playcount") - - seq.append(TopItem(Track(artist, name, self.network), playcount)) - - return seq + return self._get_things( + "user.getTopTracks", "track", Track, params, cacheable) def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" diff --git a/test_pylast.py b/test_pylast.py index ef6f4e7..056c3bd 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1040,6 +1040,88 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + def helper_top_things(self, things, expected_type): + # Assert + self.assertEqual(len(things), 2) + thing1 = things[0] + thing2 = things[1] + self.assertEqual(type(thing1), pylast.TopItem) + self.assertEqual(type(thing2), pylast.TopItem) + self.assertEqual(type(thing1.item), expected_type) + self.assertEqual(type(thing2.item), expected_type) + self.assertNotEqual(thing1, thing2) + + def test_artist_top_tracks(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_tracks(limit=2) + + # Assert + self.helper_top_things(things, pylast.Track) + + def test_artist_top_albums(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums(limit=2) + + # Assert + self.helper_top_things(things, pylast.Album) + + def test_artist_top_fans(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_fans(limit=2) + + # Assert + self.helper_top_things(things, pylast.User) + + def test_country_top_tracks(self): + # Arrange + country = self.network.get_country("Croatia") + + # Act + things = country.get_top_tracks(limit=2) + + # Assert + self.helper_top_things(things, pylast.Track) + + def test_country_network_top_tracks(self): + # Arrange + # Act + things = self.network.get_geo_top_tracks("Croatia", limit=2) + + # Assert + self.helper_top_things(things, pylast.Track) + + def test_tag_top_tracks(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + things = tag.get_top_tracks(limit=2) + + # Assert + self.helper_top_things(things, pylast.Track) + + def test_user_top_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + things = lastfm_user.get_top_tracks(limit=2) + + # Assert + self.helper_top_things(things, pylast.Track) + if __name__ == '__main__': From 73cf5b30687177d2590819376bb412c762db9459 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 14:30:02 +0200 Subject: [PATCH 101/768] Move weekly charts getters to _BaseObject, removes duplication between group, tag and user --- pylast.py | 314 ++++++++++++++++--------------------------------- test_pylast.py | 63 +++++++++- 2 files changed, 163 insertions(+), 214 deletions(-) diff --git a/pylast.py b/pylast.py index 0aa9fa7..4db9b42 100644 --- a/pylast.py +++ b/pylast.py @@ -1310,8 +1310,9 @@ class _BaseObject(object): network = None - def __init__(self, network): + def __init__(self, network, ws_prefix): self.network = network + self.ws_prefix = ws_prefix def _request(self, method_name, cacheable=False, params=None): if not params: @@ -1342,7 +1343,8 @@ class _BaseObject(object): self, method, thing, thing_type, params = None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" - doc = self._request(method, cacheable, params) + doc = self._request( + self.ws_prefix + "." + method, cacheable, params) seq = [] for node in doc.getElementsByTagName(thing): @@ -1355,11 +1357,90 @@ class _BaseObject(object): return seq + def get_weekly_chart_dates(self): + """Returns a list of From and To tuples for the available charts.""" + + doc = self._request(self.ws_prefix + ".getWeeklyChartList", True) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append((node.getAttribute("from"), node.getAttribute("to"))) + + return seq + + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + """ + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request( + self.ws_prefix + ".getWeeklyArtistChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("artist"): + item = Artist(_extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_album_charts(self, from_date=None, to_date=None): + """ + Returns the weekly album charts for the week starting from the + from_date value to the to_date value. + """ + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request( + self.ws_prefix + ".getWeeklyAlbumChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("album"): + item = Album( + _extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + def get_weekly_track_charts(self, from_date=None, to_date=None): + """ + Returns the weekly track charts for the week starting from the + from_date value to the to_date value. + """ + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request( + self.ws_prefix + ".getWeeklyTrackChart", True, params) + + seq = [] + for node in doc.getElementsByTagName("track"): + item = Track( + _extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + class _Taggable(object): """Common functions for classes with tags.""" def __init__(self, ws_prefix): - self.ws_prefix = ws_prefix + self.ws_prefix = ws_prefix # TODO move to _BaseObject def add_tags(self, tags): """Adds one or several tags. @@ -1546,7 +1627,7 @@ class Album(_BaseObject, _Taggable): * title: The album title. """ - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'album') _Taggable.__init__(self, 'album') if isinstance(artist, Artist): @@ -1734,7 +1815,7 @@ class Artist(_BaseObject, _Taggable): * name str: The artist's name. """ - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'artist') _Taggable.__init__(self, 'artist') self.name = name @@ -1883,7 +1964,7 @@ class Artist(_BaseObject, _Taggable): params['limit'] = limit return self._get_things( - "artist.getTopAlbums", "album", Album, params, cacheable) + "getTopAlbums", "album", Album, params, cacheable) def get_top_tracks(self, limit=None, cacheable=True): """Returns a list of the most played Tracks by this artist.""" @@ -1892,7 +1973,7 @@ class Artist(_BaseObject, _Taggable): params['limit'] = limit return self._get_things( - "artist.getTopTracks", "track", Track, params, cacheable) + "getTopTracks", "track", Track, params, cacheable) def get_top_fans(self, limit=None): """Returns a list of the Users who played this artist the most. @@ -2037,7 +2118,7 @@ class Event(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, event_id, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'event') self.id = event_id @@ -2250,7 +2331,7 @@ class Country(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, name, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, "geo") self.name = name @@ -2267,7 +2348,7 @@ class Country(_BaseObject): def __ne__(self, other): return self.get_name() != other.get_name() - def _get_params(self): + def _get_params(self): # TODO can move to _BaseObject return {'country': self.get_name()} def _get_name_from_code(self, alpha2code): @@ -2302,7 +2383,7 @@ class Country(_BaseObject): params['limit'] = limit return self._get_things( - "geo.getTopTracks", "track", Track, params, cacheable) + "getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the event page on the network. @@ -2336,7 +2417,7 @@ class Metro(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, name, country, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, None) self.name = name self.country = country @@ -2497,7 +2578,7 @@ class Library(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, user, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'library') if isinstance(user, User): self.user = user @@ -2665,7 +2746,7 @@ class Playlist(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, user, id, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, "playlist") if isinstance(user, User): self.user = user @@ -2801,15 +2882,12 @@ class Playlist(_BaseObject): class Tag(_BaseObject): """A Last.fm object tag.""" - # TODO: getWeeklyArtistChart - # (too lazy, i'll wait for when someone requests it) - name = None __hash__ = _BaseObject.__hash__ def __init__(self, name, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'tag') self.name = name @@ -2872,7 +2950,7 @@ class Tag(_BaseObject): params['limit'] = limit return self._get_things( - "tag.getTopTracks", "track", Track, params, cacheable) + "getTopTracks", "track", Track, params, cacheable) def get_top_artists(self): """Returns a sequence of the most played artists.""" @@ -2888,38 +2966,6 @@ class Tag(_BaseObject): return seq - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("tag.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append((node.getAttribute("from"), node.getAttribute("to"))) - - return seq - - def get_weekly_artist_charts(self, from_date=None, to_date=None): - """ - Returns the weekly artist charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("tag.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "weight")) - seq.append(TopItem(item, weight)) - - return seq - def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the tag page on the network. * domain_name: The network's language domain. Possible values: @@ -2952,7 +2998,7 @@ class Track(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'track') _Taggable.__init__(self, 'track') if isinstance(artist, Artist): @@ -3261,7 +3307,7 @@ class Group(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, group_name, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'group') self.name = group_name @@ -3285,82 +3331,6 @@ class Group(_BaseObject): """Returns the group name. """ return self.name - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("group.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append((node.getAttribute("from"), node.getAttribute("to"))) - - return seq - - def get_weekly_artist_charts(self, from_date=None, to_date=None): - """ - Returns the weekly artist charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_album_charts(self, from_date=None, to_date=None): - """ - Returns the weekly album charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyAlbumChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("album"): - item = Album( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_track_charts(self, from_date=None, to_date=None): - """ - Returns the weekly track charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("group.getWeeklyTrackChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("track"): - item = Track( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the group page on the network. * domain_name: The network's language domain. Possible values: @@ -3406,7 +3376,7 @@ class XSPF(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, uri, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, None) self.uri = uri @@ -3451,7 +3421,7 @@ class User(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, 'user') self.name = user_name @@ -3829,83 +3799,7 @@ class User(_BaseObject): params['limit'] = limit return self._get_things( - "user.getTopTracks", "track", Track, params, cacheable) - - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request("user.getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append((node.getAttribute("from"), node.getAttribute("to"))) - - return seq - - def get_weekly_artist_charts(self, from_date=None, to_date=None): - """ - Returns the weekly artist charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_album_charts(self, from_date=None, to_date=None): - """ - Returns the weekly album charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyAlbumChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("album"): - item = Album( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_track_charts(self, from_date=None, to_date=None): - """ - Returns the weekly track charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request("user.getWeeklyTrackChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("track"): - item = Track( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq + "getTopTracks", "track", Track, params, cacheable) def compare_with_user(self, user, shared_artists_limit=None): """ @@ -4046,7 +3940,7 @@ class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" def __init__(self, ws_prefix, search_terms, network): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, ws_prefix) self._ws_prefix = ws_prefix self.search_terms = search_terms @@ -4219,7 +4113,7 @@ class Venue(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, id, network, venue_element=None): - _BaseObject.__init__(self, network) + _BaseObject.__init__(self, network, "venue") self.id = _number(id) if venue_element is not None: diff --git a/test_pylast.py b/test_pylast.py index 056c3bd..3b585dc 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -857,16 +857,20 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(event), pylast.Event) self.assertEqual(event.get_venue().location['city'], "Reading") + def helper_dates_valid(self, dates): + # Assert + self.assertGreaterEqual(len(dates), 1) + self.assertEqual(type(dates[0]), tuple) + (start, end) = dates[0] + self.assertLess(start, end) + def test_get_metro_weekly_chart_dates(self): # Arrange # Act dates = self.network.get_metro_weekly_chart_dates() # Assert - self.assertGreaterEqual(len(dates), 1) - self.assertEqual(type(dates[0]), tuple) - (start, end) = dates[0] - self.assertLess(start, end) + self.helper_dates_valid(dates) def helper_geo_chart(self, function_name, expected_type=pylast.Artist): # Arrange @@ -1122,6 +1126,57 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_top_things(things, pylast.Track) + def helper_assert_chart(self, chart, expected_type): + # Assert + self.assertIsNotNone(chart) + self.assertGreater(len(chart), 0) + self.assertEqual(type(chart[0]), pylast.TopItem) + self.assertEqual(type(chart[0].item), expected_type) + + def helper_get_assert_charts(self, thing, date): + # Arrange + (from_date, to_date) = date + + # Act + artist_chart = thing.get_weekly_artist_charts(from_date, to_date) + if type(thing) is not pylast.Tag: + album_chart = thing.get_weekly_album_charts(from_date, to_date) + track_chart = thing.get_weekly_track_charts(from_date, to_date) + + # Assert + self.helper_assert_chart(artist_chart, pylast.Artist) + if type(thing) is not pylast.Tag: + self.helper_assert_chart(album_chart, pylast.Album) + self.helper_assert_chart(track_chart, pylast.Track) + + def test_group_charts(self): + # Arrange + group = self.network.get_group("mnml") + dates = group.get_weekly_chart_dates() + self.helper_dates_valid(dates) + + # Act/Assert + self.helper_get_assert_charts(group, dates[-1]) + + def test_tag_charts(self): + # Arrange + tag = self.network.get_tag("rock") + dates = tag.get_weekly_chart_dates() + self.helper_dates_valid(dates) + + # Act/Assert + self.helper_get_assert_charts(tag, dates[-1]) + + + def test_user_charts(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + dates = lastfm_user.get_weekly_chart_dates() + self.helper_dates_valid(dates) + + # Act/Assert + self.helper_get_assert_charts(lastfm_user, dates[-1]) + if __name__ == '__main__': From caea5e129aafb2f7da80f05a23a2dfbb83db1c09 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 15:23:33 +0200 Subject: [PATCH 102/768] Move get_top_fans up to _BaseObject, removing duplication in Artist and Track --- pylast.py | 70 ++++++++++++++++++-------------------------------- test_pylast.py | 64 ++++++++++++++++++++++++++++++++------------- 2 files changed, 71 insertions(+), 63 deletions(-) diff --git a/pylast.py b/pylast.py index 4db9b42..63ef8fd 100644 --- a/pylast.py +++ b/pylast.py @@ -1373,7 +1373,6 @@ class _BaseObject(object): Returns the weekly artist charts for the week starting from the from_date value to the to_date value. """ - params = self._get_params() if from_date and to_date: params["from"] = from_date @@ -1436,6 +1435,31 @@ class _BaseObject(object): return seq + def get_top_fans(self, limit=None, cacheable=True): + """Returns a list of the Users who played this the most. + # Parameters: + * limit int: Max elements. + # For Artist/Track + """ + + doc = self._request(self.ws_prefix + '.getTopFans', cacheable) + + seq = [] + + elements = doc.getElementsByTagName('user') + + for element in elements: + if limit and len(seq) >= limit: + break + + name = _extract(element, 'name') + weight = _number(_extract(element, 'weight')) + + seq.append(TopItem(User(name, self.network), weight)) + + return seq + + class _Taggable(object): """Common functions for classes with tags.""" @@ -1975,29 +1999,6 @@ class Artist(_BaseObject, _Taggable): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def get_top_fans(self, limit=None): - """Returns a list of the Users who played this artist the most. - # Parameters: - * limit int: Max elements. - """ - - doc = self._request('artist.getTopFans', True) - - seq = [] - - elements = doc.getElementsByTagName('user') - - for element in elements: - if limit and len(seq) >= limit: - break - - name = _extract(element, 'name') - weight = _number(_extract(element, 'weight')) - - seq.append(TopItem(User(name, self.network), weight)) - - return seq - def share(self, users, message=None): """Shares this artist (sends out recommendations). # Parameters: @@ -2322,7 +2323,6 @@ class Event(_BaseObject): self._request("event.Shout", False, params) - class Country(_BaseObject): """A country at Last.fm.""" @@ -3210,26 +3210,6 @@ class Track(_BaseObject, _Taggable): return seq - def get_top_fans(self, limit=None): - """Returns a list of the Users who played this track.""" - - doc = self._request('track.getTopFans', True) - - seq = [] - - elements = doc.getElementsByTagName('user') - - for element in elements: - if limit and len(seq) >= limit: - break - - name = _extract(element, 'name') - weight = _number(_extract(element, 'weight')) - - seq.append(TopItem(User(name, self.network), weight)) - - return seq - def share(self, users, message=None): """Shares this track (sends out recommendations). * users: A list that can contain usernames, emails, User objects, diff --git a/test_pylast.py b/test_pylast.py index 3b585dc..976730b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1003,6 +1003,13 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:track:", links[0]) self.assertIn("spotify:track:", links[1]) + def helper_at_least_one_thing_in_top_list(self, things, expected_type): + # Assert + self.assertGreater(len(things), 1) + self.assertEqual(type(things), list) + self.assertEqual(type(things[0]), pylast.TopItem) + self.assertEqual(type(things[0].item), expected_type) + def helper_only_one_thing_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 1) @@ -1010,6 +1017,17 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(things[0]), pylast.TopItem) self.assertEqual(type(things[0].item), expected_type) + def helper_two_different_things_in_top_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 2) + thing1 = things[0] + thing2 = things[1] + self.assertEqual(type(thing1), pylast.TopItem) + self.assertEqual(type(thing2), pylast.TopItem) + self.assertEqual(type(thing1.item), expected_type) + self.assertEqual(type(thing2.item), expected_type) + self.assertNotEqual(thing1, thing2) + def test_user_get_top_tags_with_limit(self): # Arrange user = self.network.get_user("RJ") @@ -1044,17 +1062,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) - def helper_top_things(self, things, expected_type): - # Assert - self.assertEqual(len(things), 2) - thing1 = things[0] - thing2 = things[1] - self.assertEqual(type(thing1), pylast.TopItem) - self.assertEqual(type(thing2), pylast.TopItem) - self.assertEqual(type(thing1.item), expected_type) - self.assertEqual(type(thing2.item), expected_type) - self.assertNotEqual(thing1, thing2) - def test_artist_top_tracks(self): # Arrange # Pick an artist with plenty of plays @@ -1064,7 +1071,7 @@ class TestPyLast(unittest.TestCase): things = artist.get_top_tracks(limit=2) # Assert - self.helper_top_things(things, pylast.Track) + self.helper_two_different_things_in_top_list(things, pylast.Track) def test_artist_top_albums(self): # Arrange @@ -1075,7 +1082,7 @@ class TestPyLast(unittest.TestCase): things = artist.get_top_albums(limit=2) # Assert - self.helper_top_things(things, pylast.Album) + self.helper_two_different_things_in_top_list(things, pylast.Album) def test_artist_top_fans(self): # Arrange @@ -1086,7 +1093,7 @@ class TestPyLast(unittest.TestCase): things = artist.get_top_fans(limit=2) # Assert - self.helper_top_things(things, pylast.User) + self.helper_two_different_things_in_top_list(things, pylast.User) def test_country_top_tracks(self): # Arrange @@ -1096,7 +1103,7 @@ class TestPyLast(unittest.TestCase): things = country.get_top_tracks(limit=2) # Assert - self.helper_top_things(things, pylast.Track) + self.helper_two_different_things_in_top_list(things, pylast.Track) def test_country_network_top_tracks(self): # Arrange @@ -1104,7 +1111,7 @@ class TestPyLast(unittest.TestCase): things = self.network.get_geo_top_tracks("Croatia", limit=2) # Assert - self.helper_top_things(things, pylast.Track) + self.helper_two_different_things_in_top_list(things, pylast.Track) def test_tag_top_tracks(self): # Arrange @@ -1114,7 +1121,7 @@ class TestPyLast(unittest.TestCase): things = tag.get_top_tracks(limit=2) # Assert - self.helper_top_things(things, pylast.Track) + self.helper_two_different_things_in_top_list(things, pylast.Track) def test_user_top_tracks(self): # Arrange @@ -1124,7 +1131,7 @@ class TestPyLast(unittest.TestCase): things = lastfm_user.get_top_tracks(limit=2) # Assert - self.helper_top_things(things, pylast.Track) + self.helper_two_different_things_in_top_list(things, pylast.Track) def helper_assert_chart(self, chart, expected_type): # Assert @@ -1178,6 +1185,27 @@ class TestPyLast(unittest.TestCase): self.helper_get_assert_charts(lastfm_user, dates[-1]) + def test_artist_top_fans(self): + # Arrange + artist = self.network.get_artist("Test Artist") + + # Act + fans = artist.get_top_fans() + + # Assert + self.helper_at_least_one_thing_in_top_list(fans, pylast.User) + + def test_track_top_fans(self): + # Arrange + track = self.network.get_track("The Cinematic Orchestra", "Postlude") + + # Act + fans = track.get_top_fans() + + # Assert + self.helper_at_least_one_thing_in_top_list(fans, pylast.User) + + if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") From 0747d5d20edaec6f6b908e3bb0cfb13e352020d5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 16:21:19 +0200 Subject: [PATCH 103/768] Move share() up to _BaseObject, removing duplication in Artist, Event and Track --- pylast.py | 115 +++++++++++++------------------------------------ test_pylast.py | 17 ++++++++ 2 files changed, 47 insertions(+), 85 deletions(-) diff --git a/pylast.py b/pylast.py index 63ef8fd..6671b7f 100644 --- a/pylast.py +++ b/pylast.py @@ -1459,6 +1459,36 @@ class _BaseObject(object): return seq + def share(self, users, message=None): + """ + Shares this (sends out recommendations). + Parameters: + * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. + * message str: A message to include in the recommendation message. + Only for Artist/Event/Track. + """ + + # Last.fm currently accepts a max of 10 recipient at a time + while(len(users) > 10): + section = users[0:9] + users = users[9:] + self.share(section, message) + + nusers = [] + for user in users: + if isinstance(user, User): + nusers.append(user.get_name()) + else: + nusers.append(user) + + params = self._get_params() + recipients = ','.join(nusers) + params['recipient'] = recipients + if message: + params['message'] = message + + self._request(self.ws_prefix + '.share', False, params) + class _Taggable(object): """Common functions for classes with tags.""" @@ -1999,35 +2029,6 @@ class Artist(_BaseObject, _Taggable): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def share(self, users, message=None): - """Shares this artist (sends out recommendations). - # Parameters: - * users [User|str,]: A list that can contain usernames, emails, - User objects, or all of them. - * message str: A message to include in the recommendation message. - """ - - # Last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('artist.share', False, params) - def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the artist page on the network. # Parameters: @@ -2269,34 +2270,6 @@ class Event(_BaseObject): return self.network._get_url( domain_name, "event") % {'id': self.get_id()} - def share(self, users, message=None): - """Shares this event (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, - or all of them. - * message: A message to include in the recommendation message. - """ - - # Last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('event.share', False, params) - def get_shouts(self, limit=50, cacheable=False): """ Returns a sequqence of Shout objects @@ -3210,34 +3183,6 @@ class Track(_BaseObject, _Taggable): return seq - def share(self, users, message=None): - """Shares this track (sends out recommendations). - * users: A list that can contain usernames, emails, User objects, - or all of them. - * message: A message to include in the recommendation message. - """ - - # Last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): - section = users[0:9] - users = users[9:] - self.share(section, message) - - nusers = [] - for user in users: - if isinstance(user, User): - nusers.append(user.get_name()) - else: - nusers.append(user) - - params = self._get_params() - recipients = ','.join(nusers) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request('track.share', False, params) - def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the track page on the network. * domain_name: The network's language domain. Possible values: diff --git a/test_pylast.py b/test_pylast.py index 976730b..7f7534c 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1205,6 +1205,23 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_at_least_one_thing_in_top_list(fans, pylast.User) + # Commented out to avoid spamming + # def test_share_spam(self): + # # Arrange + # users_to_spam = [TODO_ENTER_SPAMEES_HERE] + # spam_message = "Dig the krazee sound!" + # artist = self.network.get_top_artists(limit=1)[0].item + # track = artist.get_top_tracks(limit=1)[0].item + # event = artist.get_upcoming_events()[0] + + # # Act + # artist.share(users_to_spam, spam_message) + # track.share(users_to_spam, spam_message) + # event.share(users_to_spam, spam_message) + + # Assert + # Check inbox for spam! + if __name__ == '__main__': From 7cb8a654ec7402cbaffd058c86aa7043ee50782d Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 16:26:50 +0200 Subject: [PATCH 104/768] Move get_wiki_published_date(), get_wiki_summary(), get_wiki_content() up to _BaseObject to remove duplication in Album and Track. Already has test cases. --- pylast.py | 117 +++++++++++++++++++++--------------------------------- 1 file changed, 45 insertions(+), 72 deletions(-) diff --git a/pylast.py b/pylast.py index 6671b7f..8f57442 100644 --- a/pylast.py +++ b/pylast.py @@ -1489,6 +1489,51 @@ class _BaseObject(object): self._request(self.ws_prefix + '.share', False, params) + def get_wiki_published_date(self): + """ + Returns the date of publishing this version of the wiki. + Only for Album/Track. + """ + + doc = self._request(self.ws_prefix + ".getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "published") + + def get_wiki_summary(self): + """ + Returns the summary of the wiki. + Only for Album/Track. + """ + + doc = self._request(self.ws_prefix + ".getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "summary") + + def get_wiki_content(self): + """ + Returns the content of the wiki. + Only for Album/Track. + """ + + doc = self._request(self.ws_prefix + ".getInfo", True) + + if len(doc.getElementsByTagName("wiki")) == 0: + return + + node = doc.getElementsByTagName("wiki")[0] + + return _extract(node, "content") + class _Taggable(object): """Common functions for classes with tags.""" @@ -1818,42 +1863,6 @@ class Album(_BaseObject, _Taggable): return self.network._get_url( domain_name, "album") % {'artist': artist, 'album': album} - def get_wiki_published_date(self): - """Returns the date of publishing this version of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "published") - - def get_wiki_summary(self): - """Returns the summary of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "summary") - - def get_wiki_content(self): - """Returns the content of the wiki.""" - - doc = self._request("album.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "content") - class Artist(_BaseObject, _Taggable): """An artist.""" @@ -3114,42 +3123,6 @@ class Track(_BaseObject, _Taggable): return Album( _extract(node, "artist"), _extract(node, "title"), self.network) - def get_wiki_published_date(self): - """Returns the date of publishing this version of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "published") - - def get_wiki_summary(self): - """Returns the summary of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "summary") - - def get_wiki_content(self): - """Returns the content of the wiki.""" - - doc = self._request("track.getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "content") - def love(self): """Adds the track to the user's loved tracks. """ From ac87cee29d461167af19e7261af7afd38273dd7f Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 18:11:39 +0200 Subject: [PATCH 105/768] Refactor shouts to duplicate code --- pylast.py | 313 +++++++++++++++++++++---------------------------- test_pylast.py | 83 ++++++++++--- 2 files changed, 201 insertions(+), 195 deletions(-) diff --git a/pylast.py b/pylast.py index 8f57442..0863fdd 100644 --- a/pylast.py +++ b/pylast.py @@ -1340,7 +1340,7 @@ class _BaseObject(object): tag_name)[0].firstChild.wholeText.strip() def _get_things( - self, method, thing, thing_type, params = None, cacheable=True): + self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" doc = self._request( @@ -1368,55 +1368,14 @@ class _BaseObject(object): return seq - def get_weekly_artist_charts(self, from_date=None, to_date=None): - """ - Returns the weekly artist charts for the week starting from the - from_date value to the to_date value. - """ - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request( - self.ws_prefix + ".getWeeklyArtistChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("artist"): - item = Artist(_extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_album_charts(self, from_date=None, to_date=None): - """ - Returns the weekly album charts for the week starting from the - from_date value to the to_date value. - """ - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request( - self.ws_prefix + ".getWeeklyAlbumChart", True, params) - - seq = [] - for node in doc.getElementsByTagName("album"): - item = Album( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - - def get_weekly_track_charts(self, from_date=None, to_date=None): + def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): """ Returns the weekly track charts for the week starting from the from_date value to the to_date value. + chart_kind should be one of "album", "track" """ + method = ".getWeekly" + chart_kind.title() + "Chart" + chart_type = eval(chart_kind.title()) params = self._get_params() if from_date and to_date: @@ -1424,11 +1383,11 @@ class _BaseObject(object): params["to"] = to_date doc = self._request( - self.ws_prefix + ".getWeeklyTrackChart", True, params) + self.ws_prefix + method, True, params) seq = [] - for node in doc.getElementsByTagName("track"): - item = Track( + for node in doc.getElementsByTagName(chart_kind.lower()): + item = chart_type( _extract(node, "artist"), _extract(node, "name"), self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) @@ -1463,7 +1422,8 @@ class _BaseObject(object): """ Shares this (sends out recommendations). Parameters: - * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. + * users [User|str,]: A list that can contain usernames, emails, + User objects, or all of them. * message str: A message to include in the recommendation message. Only for Artist/Event/Track. """ @@ -1492,7 +1452,7 @@ class _BaseObject(object): def get_wiki_published_date(self): """ Returns the date of publishing this version of the wiki. - Only for Album/Track. + Only for Album/Tag/Track. """ doc = self._request(self.ws_prefix + ".getInfo", True) @@ -1534,12 +1494,32 @@ class _BaseObject(object): return _extract(node, "content") + def get_shouts(self, limit=50, cacheable=False): + """ + Returns a sequence of Shout objects + """ + + shouts = [] + for node in _collect_nodes( + limit, + self, + self.ws_prefix + ".getShouts", + cacheable): + shouts.append( + Shout( + _extract(node, "body"), + User(_extract(node, "author"), self.network), + _extract(node, "date") + ) + ) + return shouts + class _Taggable(object): """Common functions for classes with tags.""" def __init__(self, ws_prefix): - self.ws_prefix = ws_prefix # TODO move to _BaseObject + self.ws_prefix = ws_prefix # TODO move to _BaseObject def add_tags(self, tags): """Adds one or several tags. @@ -1712,8 +1692,8 @@ class NetworkError(Exception): class Album(_BaseObject, _Taggable): """An album.""" - title = None artist = None + title = None username = None __hash__ = _BaseObject.__hash__ @@ -1762,7 +1742,8 @@ class Album(_BaseObject, _Taggable): def _get_params(self): return { - 'artist': self.get_artist().get_name(), 'album': self.get_title(), + 'artist': self.get_artist().get_name(), + self.ws_prefix: self.get_title(), } def get_artist(self): @@ -1781,10 +1762,10 @@ class Album(_BaseObject, _Taggable): return self.get_title() def get_release_date(self): - """Retruns the release date of the album.""" + """Returns the release date of the album.""" - return _extract( - self._request("album.getInfo", cacheable=True), "releasedate") + return _extract(self._request( + self.ws_prefix + ".getInfo", cacheable=True), "releasedate") def get_cover_image(self, size=COVER_EXTRA_LARGE): """ @@ -1797,18 +1778,21 @@ class Album(_BaseObject, _Taggable): """ return _extract_all( - self._request("album.getInfo", cacheable=True), 'image')[size] + self._request( + self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] def get_id(self): """Returns the ID""" - return _extract(self._request("album.getInfo", cacheable=True), "id") + return _extract( + self._request(self.ws_prefix + ".getInfo", cacheable=True), "id") def get_playcount(self): """Returns the number of plays on the network""" return _number(_extract( - self._request("album.getInfo", cacheable=True), "playcount")) + self._request( + self.ws_prefix + ".getInfo", cacheable=True), "playcount")) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1819,13 +1803,15 @@ class Album(_BaseObject, _Taggable): params['username'] = self.username return _number(_extract( - self._request("album.getInfo", True, params), "userplaycount")) + self._request( + self.ws_prefix + ".getInfo", True, params), "userplaycount")) def get_listener_count(self): """Returns the number of listeners on the network""" return _number(_extract( - self._request("album.getInfo", cacheable=True), "listeners")) + self._request( + self.ws_prefix + ".getInfo", cacheable=True), "listeners")) def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -1837,7 +1823,8 @@ class Album(_BaseObject, _Taggable): def get_mbid(self): """Returns the MusicBrainz id of the album.""" - return _extract(self._request("album.getInfo", cacheable=True), "mbid") + return _extract( + self._request(self.ws_prefix + ".getInfo", cacheable=True), "mbid") def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the album page on the network. @@ -1861,7 +1848,7 @@ class Album(_BaseObject, _Taggable): album = _url_safe(self.get_title()) return self.network._get_url( - domain_name, "album") % {'artist': artist, 'album': album} + domain_name, "album") % {'artist': artist, self.ws_prefix: album} class Artist(_BaseObject, _Taggable): @@ -2094,22 +2081,6 @@ class Artist(_BaseObject, _Taggable): ) return images - def get_shouts(self, limit=50, cacheable=False): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "artist.getShouts", cacheable): - shouts.append( - Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - def shout(self, message): """ Post a shout @@ -2279,22 +2250,6 @@ class Event(_BaseObject): return self.network._get_url( domain_name, "event") % {'id': self.get_id()} - def get_shouts(self, limit=50, cacheable=False): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "event.getShouts", cacheable): - shouts.append( - Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - def shout(self, message): """ Post a shout @@ -2305,6 +2260,7 @@ class Event(_BaseObject): self._request("event.Shout", False, params) + class Country(_BaseObject): """A country at Last.fm.""" @@ -2330,7 +2286,7 @@ class Country(_BaseObject): def __ne__(self, other): return self.get_name() != other.get_name() - def _get_params(self): # TODO can move to _BaseObject + def _get_params(self): # TODO can move to _BaseObject return {'country': self.get_name()} def _get_name_from_code(self, alpha2code): @@ -2980,6 +2936,12 @@ class Track(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, username=None): + """ + Create a track instance. + # Parameters: + * artist: An artist name or an Artist object. + * title: The artist title. + """ _BaseObject.__init__(self, network, 'track') _Taggable.__init__(self, 'track') @@ -2989,7 +2951,6 @@ class Track(_BaseObject, _Taggable): self.artist = Artist(artist, self.network) self.title = title - self.username = username def __repr__(self): @@ -3016,7 +2977,8 @@ class Track(_BaseObject, _Taggable): def _get_params(self): return { - 'artist': self.get_artist().get_name(), 'track': self.get_title()} + 'artist': self.get_artist().get_name(), + self.ws_prefix: self.get_title()} def get_artist(self): """Returns the associated Artist object.""" @@ -3027,7 +2989,8 @@ class Track(_BaseObject, _Taggable): """Returns the track title.""" if properly_capitalized: - self.title = _extract(self._request("track.getInfo", True), "name") + self.title = _extract( + self._request(self.ws_prefix + ".getInfo", True), "name") return self.title @@ -3039,21 +3002,21 @@ class Track(_BaseObject, _Taggable): def get_id(self): """Returns the track id on the network.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "id") def get_duration(self): """Returns the track duration.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "duration")) def get_mbid(self): """Returns the MusicBrainz ID of this track.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "mbid") @@ -3063,14 +3026,14 @@ class Track(_BaseObject, _Taggable): if hasattr(self, "listener_count"): return self.listener_count else: - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) self.listener_count = _number(_extract(doc, "listeners")) return self.listener_count def get_playcount(self): """Returns the play count.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "playcount")) def get_userplaycount(self): @@ -3081,7 +3044,7 @@ class Track(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - doc = self._request("track.getInfo", True, params) + doc = self._request(self.ws_prefix + ".getInfo", True, params) return _number(_extract(doc, "userplaycount")) def get_userloved(self): @@ -3092,27 +3055,27 @@ class Track(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - doc = self._request("track.getInfo", True, params) + doc = self._request(self.ws_prefix + ".getInfo", True, params) loved = _number(_extract(doc, "userloved")) return bool(loved) def is_streamable(self): """Returns True if the track is available at Last.fm.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "streamable") == "1" def is_fulltrack_available(self): """Returns True if the fulltrack is available for streaming.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return doc.getElementsByTagName( "streamable")[0].getAttribute("fulltrack") == "1" def get_album(self): """Returns the album object of this track.""" - doc = self._request("track.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) albums = doc.getElementsByTagName("album") @@ -3126,17 +3089,17 @@ class Track(_BaseObject, _Taggable): def love(self): """Adds the track to the user's loved tracks. """ - self._request('track.love') + self._request(self.ws_prefix + '.love') def unlove(self): """Remove the track to the user's loved tracks. """ - self._request('track.unlove') + self._request(self.ws_prefix + '.unlove') def ban(self): """Ban this track from ever playing on the radio. """ - self._request('track.ban') + self._request(self.ws_prefix + '.ban') def get_similar(self): """ @@ -3144,10 +3107,10 @@ class Track(_BaseObject, _Taggable): based on listening data. """ - doc = self._request('track.getSimilar', True) + doc = self._request(self.ws_prefix + '.getSimilar', True) seq = [] - for node in doc.getElementsByTagName("track"): + for node in doc.getElementsByTagName(self.ws_prefix): title = _extract(node, 'name') artist = _extract(node, 'name', 1) match = _number(_extract(node, "match")) @@ -3180,22 +3143,6 @@ class Track(_BaseObject, _Taggable): 'domain': self.network._get_language_domain(domain_name), 'artist': artist, 'title': title} - def get_shouts(self, limit=50, cacheable=False): - """ - Returns a sequqence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "track.getShouts", cacheable): - shouts.append( - Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - class Group(_BaseObject): """A Last.fm group.""" @@ -3223,7 +3170,7 @@ class Group(_BaseObject): return self.get_name() != other.get_name() def _get_params(self): - return {'group': self.get_name()} + return {self.ws_prefix: self.get_name()} def get_name(self): """Returns the group name. """ @@ -3256,7 +3203,8 @@ class Group(_BaseObject): if limit==None it will return all """ - nodes = _collect_nodes(limit, self, "group.getMembers", cacheable) + nodes = _collect_nodes( + limit, self, self.ws_prefix + ".getMembers", cacheable) users = [] @@ -3347,20 +3295,21 @@ class User(_BaseObject): return True def _get_params(self): - return {"user": self.get_name()} + return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized=False): """Returns the user name.""" if properly_capitalized: - self.name = _extract(self._request("user.getInfo", True), "name") + self.name = _extract( + self._request(self.ws_prefix + ".getInfo", True), "name") return self.name def get_upcoming_events(self): """Returns all the upcoming events for this user.""" - doc = self._request('user.getEvents', True) + doc = self._request(self.ws_prefix + '.getEvents', True) return _extract_events_from_doc(doc, self.network) @@ -3377,7 +3326,11 @@ class User(_BaseObject): seq = [] for track in _collect_nodes( - None, self, "user.getArtistTracks", cacheable, params): + None, + self, + self.ws_prefix + ".getArtistTracks", + cacheable, + params): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") @@ -3394,7 +3347,11 @@ class User(_BaseObject): """Returns a list of the user's friends. """ seq = [] - for node in _collect_nodes(limit, self, "user.getFriends", cacheable): + for node in _collect_nodes( + limit, + self, + self.ws_prefix + ".getFriends", + cacheable): seq.append(User(_extract(node, "name"), self.network)) return seq @@ -3419,8 +3376,11 @@ class User(_BaseObject): seq = [] for track in _collect_nodes( - limit, self, "user.getLovedTracks", cacheable, params): - + limit, + self, + self.ws_prefix + ".getLovedTracks", + cacheable, + params): title = _extract(track, "name") artist = _extract(track, "name", 1) date = _extract(track, "date") @@ -3439,7 +3399,7 @@ class User(_BaseObject): if limit: params['limit'] = limit - doc = self._request('user.getNeighbours', True, params) + doc = self._request(self.ws_prefix + '.getNeighbours', True, params) seq = [] names = _extract_all(doc, 'name') @@ -3456,7 +3416,11 @@ class User(_BaseObject): """ seq = [] - for n in _collect_nodes(limit, self, "user.getPastEvents", cacheable): + for n in _collect_nodes( + limit, + self, + self.ws_prefix + ".getPastEvents", + cacheable): seq.append(Event(_extract(n, "id"), self.network)) return seq @@ -3464,7 +3428,7 @@ class User(_BaseObject): def get_playlists(self): """Returns a list of Playlists that this user owns.""" - doc = self._request("user.getPlaylists", True) + doc = self._request(self.ws_prefix + ".getPlaylists", True) playlists = [] for playlist_id in _extract_all(doc, "id"): @@ -3481,7 +3445,7 @@ class User(_BaseObject): params = self._get_params() params['limit'] = '1' - doc = self._request('user.getRecentTracks', False, params) + doc = self._request(self.ws_prefix + '.getRecentTracks', False, params) tracks = doc.getElementsByTagName('track') @@ -3518,7 +3482,11 @@ class User(_BaseObject): seq = [] for track in _collect_nodes( - limit, self, "user.getRecentTracks", cacheable, params): + limit, + self, + self.ws_prefix + ".getRecentTracks", + cacheable, + params): if track.hasAttribute('nowplaying'): continue # to prevent the now playing track from sneaking in @@ -3536,23 +3504,23 @@ class User(_BaseObject): return seq def get_id(self): - """Returns the user id.""" + """Returns the user ID.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "id") def get_language(self): """Returns the language code of the language used by the user.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "lang") def get_country(self): """Returns the name of the country of the user.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) country = _extract(doc, "country") @@ -3564,14 +3532,14 @@ class User(_BaseObject): def get_age(self): """Returns the user's age.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "age")) def get_gender(self): """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) value = _extract(doc, "gender") @@ -3585,28 +3553,28 @@ class User(_BaseObject): def is_subscriber(self): """Returns whether the user is a subscriber or not. True or False.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "subscriber") == "1" def get_playcount(self): """Returns the user's playcount so far.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _number(_extract(doc, "playcount")) def get_registered(self): """Returns the user's registration date.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "registered") def get_unixtime_registered(self): """Returns the user's registration date as a UNIX timestamp.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return doc.getElementsByTagName( "registered")[0].getAttribute("unixtime") @@ -3624,7 +3592,7 @@ class User(_BaseObject): params = self._get_params() params['period'] = period - doc = self._request('user.getTopAlbums', True, params) + doc = self._request(self.ws_prefix + '.getTopAlbums', True, params) seq = [] for album in doc.getElementsByTagName('album'): @@ -3649,7 +3617,7 @@ class User(_BaseObject): params = self._get_params() params['period'] = period - doc = self._request('user.getTopArtists', True, params) + doc = self._request(self.ws_prefix + '.getTopArtists', True, params) seq = [] for node in doc.getElementsByTagName('artist'): @@ -3671,7 +3639,7 @@ class User(_BaseObject): params = self._get_params() if limit: params["limit"] = limit - doc = self._request("user.getTopTags", cacheable, params) + doc = self._request(self.ws_prefix + ".getTopTags", cacheable, params) seq = [] for node in doc.getElementsByTagName("tag"): @@ -3681,7 +3649,8 @@ class User(_BaseObject): return seq - def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True): + def get_top_tracks( + self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3735,7 +3704,7 @@ class User(_BaseObject): def get_image(self): """Returns the user's avatar.""" - doc = self._request("user.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "image") @@ -3765,22 +3734,6 @@ class User(_BaseObject): return Library(self, self.network) - def get_shouts(self, limit=50, cacheable=False): - """ - Returns a sequence of Shout objects - """ - - shouts = [] - for node in _collect_nodes(limit, self, "user.getShouts", cacheable): - shouts.append( - Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - def shout(self, message): """ Post a shout @@ -3789,7 +3742,7 @@ class User(_BaseObject): params = self._get_params() params["message"] = message - self._request("user.Shout", False, params) + self._request(self.ws_prefix + ".Shout", False, params) class AuthenticatedUser(User): @@ -4031,7 +3984,7 @@ class Venue(_BaseObject): return self.get_id() == other.get_id() def _get_params(self): - return {"venue": self.get_id()} + return {self.ws_prefix: self.get_id()} def get_id(self): """Returns the id of the venue.""" @@ -4056,14 +4009,14 @@ class Venue(_BaseObject): def get_upcoming_events(self): """Returns the upcoming events in this venue.""" - doc = self._request("venue.getEvents", True) + doc = self._request(self.ws_prefix + ".getEvents", True) return _extract_events_from_doc(doc, self.network) def get_past_events(self): """Returns the past events held in this venue.""" - doc = self._request("venue.getEvents", True) + doc = self._request(self.ws_prefix + ".getEvents", True) return _extract_events_from_doc(doc, self.network) diff --git a/test_pylast.py b/test_pylast.py index 7f7534c..3856746 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1028,6 +1028,15 @@ class TestPyLast(unittest.TestCase): self.assertEqual(type(thing2.item), expected_type) self.assertNotEqual(thing1, thing2) + def helper_two_things_in_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 2) + self.assertEqual(type(things), list) + thing1 = things[0] + thing2 = things[1] + self.assertEqual(type(thing1), expected_type) + self.assertEqual(type(thing2), expected_type) + def test_user_get_top_tags_with_limit(self): # Arrange user = self.network.get_user("RJ") @@ -1145,10 +1154,10 @@ class TestPyLast(unittest.TestCase): (from_date, to_date) = date # Act - artist_chart = thing.get_weekly_artist_charts(from_date, to_date) + artist_chart = thing.get_weekly_charts("artist", from_date, to_date) if type(thing) is not pylast.Tag: - album_chart = thing.get_weekly_album_charts(from_date, to_date) - track_chart = thing.get_weekly_track_charts(from_date, to_date) + album_chart = thing.get_weekly_charts("album", from_date, to_date) + track_chart = thing.get_weekly_charts("track", from_date, to_date) # Assert self.helper_assert_chart(artist_chart, pylast.Artist) @@ -1174,7 +1183,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(tag, dates[-1]) - def test_user_charts(self): # Arrange lastfm_user = self.network.get_user("RJ") @@ -1184,17 +1192,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[-1]) - - def test_artist_top_fans(self): - # Arrange - artist = self.network.get_artist("Test Artist") - - # Act - fans = artist.get_top_fans() - - # Assert - self.helper_at_least_one_thing_in_top_list(fans, pylast.User) - def test_track_top_fans(self): # Arrange track = self.network.get_track("The Cinematic Orchestra", "Postlude") @@ -1222,6 +1219,62 @@ class TestPyLast(unittest.TestCase): # Assert # Check inbox for spam! + #album/artist/event/track/user + + def test_album_shouts(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + album = artist.get_top_albums(limit=1)[0].item + + # Act + shouts = album.get_shouts(limit=2) + + # Assert + self.helper_two_things_in_list(shouts, pylast.Shout) + + def test_artist_shouts(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + shouts = artist.get_shouts(limit=2) + + # Assert + self.helper_two_things_in_list(shouts, pylast.Shout) + + def test_event_shouts(self): + # Arrange + event_id = 3478520 # Glasto 2014 + event = pylast.Event(event_id, self.network) + + # Act + shouts = event.get_shouts(limit=2) + + # Assert + self.helper_two_things_in_list(shouts, pylast.Shout) + + def test_track_shouts(self): + # Arrange + track = self.network.get_track("The Cinematic Orchestra", "Postlude") + + # Act + shouts = track.get_shouts(limit=2) + + # Assert + self.helper_two_things_in_list(shouts, pylast.Shout) + + def test_user_shouts(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + shouts = user.get_shouts(limit=2) + + # Assert + self.helper_two_things_in_list(shouts, pylast.Shout) + if __name__ == '__main__': From 342bb94045fbedd9a958c9dcc2878054fd0aa4c9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 6 Mar 2014 23:05:23 +0200 Subject: [PATCH 106/768] Refactor get_wiki things to remove duplication --- pylast.py | 36 ++++-------------------------------- test_pylast.py | 20 ++++++++++++++++---- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/pylast.py b/pylast.py index 0863fdd..197ff5c 100644 --- a/pylast.py +++ b/pylast.py @@ -1449,25 +1449,12 @@ class _BaseObject(object): self._request(self.ws_prefix + '.share', False, params) - def get_wiki_published_date(self): - """ - Returns the date of publishing this version of the wiki. - Only for Album/Tag/Track. - """ - - doc = self._request(self.ws_prefix + ".getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "published") - - def get_wiki_summary(self): + def get_wiki(self, section): """ Returns the summary of the wiki. Only for Album/Track. + section can be "content", "summary" or "published" + (for published date) """ doc = self._request(self.ws_prefix + ".getInfo", True) @@ -1477,22 +1464,7 @@ class _BaseObject(object): node = doc.getElementsByTagName("wiki")[0] - return _extract(node, "summary") - - def get_wiki_content(self): - """ - Returns the content of the wiki. - Only for Album/Track. - """ - - doc = self._request(self.ws_prefix + ".getInfo", True) - - if len(doc.getElementsByTagName("wiki")) == 0: - return - - node = doc.getElementsByTagName("wiki")[0] - - return _extract(node, "content") + return _extract(node, section) def get_shouts(self, limit=50, cacheable=False): """ diff --git a/test_pylast.py b/test_pylast.py index 3856746..d5e53a6 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -680,7 +680,18 @@ class TestPyLast(unittest.TestCase): album = pylast.Album("Test Artist", "Test Album", self.network) # Act - wiki = album.get_wiki_content() + wiki = album.get_wiki("content") + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_album_wiki_published(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki("published") # Assert self.assertIsNotNone(wiki) @@ -691,7 +702,7 @@ class TestPyLast(unittest.TestCase): album = pylast.Album("Test Artist", "Test Album", self.network) # Act - wiki = album.get_wiki_summary() + wiki = album.get_wiki("summary") # Assert self.assertIsNotNone(wiki) @@ -702,7 +713,7 @@ class TestPyLast(unittest.TestCase): track = pylast.Track("Test Artist", "Test Title", self.network) # Act - wiki = track.get_wiki_content() + wiki = track.get_wiki("content") # Assert self.assertIsNotNone(wiki) @@ -713,7 +724,7 @@ class TestPyLast(unittest.TestCase): track = pylast.Track("Test Artist", "Test Title", self.network) # Act - wiki = track.get_wiki_summary() + wiki = track.get_wiki("summary") # Assert self.assertIsNotNone(wiki) @@ -1283,6 +1294,7 @@ if __name__ == '__main__': if test is not None and len(test): suite = unittest.TestSuite() + suite.addTest(TestPyLast(test)) unittest.TextTestRunner().run(suite) else: From d3dba1475ab1615aae421d1e418f9134554c561c Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 09:41:21 +0200 Subject: [PATCH 107/768] Refactor and mark artist.getimages as deprecated --- pylast.py | 130 +++++++++++++++++++++++++----------------------------- 1 file changed, 59 insertions(+), 71 deletions(-) diff --git a/pylast.py b/pylast.py index 197ff5c..4f9aacb 100644 --- a/pylast.py +++ b/pylast.py @@ -1774,9 +1774,8 @@ class Album(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - return _number(_extract( - self._request( - self.ws_prefix + ".getInfo", True, params), "userplaycount")) + doc = self._request(self.ws_prefix + ".getInfo", True, params) + return _number(_extract(doc, "userplaycount")) def get_listener_count(self): """Returns the number of listeners on the network""" @@ -1858,7 +1857,7 @@ class Artist(_BaseObject, _Taggable): return self.get_name().lower() != other.get_name().lower() def _get_params(self): - return {'artist': self.get_name()} + return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized=False): """Returns the name of the artist. @@ -1866,7 +1865,8 @@ class Artist(_BaseObject, _Taggable): overwriting the given one.""" if properly_capitalized: - self.name = _extract(self._request("artist.getInfo", True), "name") + self.name = _extract( + self._request(self.ws_prefix + ".getInfo", True), "name") return self.name @@ -1882,13 +1882,13 @@ class Artist(_BaseObject, _Taggable): """ return _extract_all( - self._request("artist.getInfo", True), "image")[size] + self._request(self.ws_prefix + ".getInfo", True), "image")[size] def get_playcount(self): """Returns the number of plays on the network.""" return _number(_extract( - self._request("artist.getInfo", True), "playcount")) + self._request(self.ws_prefix + ".getInfo", True), "playcount")) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1898,13 +1898,13 @@ class Artist(_BaseObject, _Taggable): params = self._get_params() params['username'] = self.username - return _number(_extract( - self._request("artist.getInfo", True, params), "userplaycount")) + doc = self._request(self.ws_prefix + ".getInfo", True, params) + return _number(_extract(doc, "userplaycount")) def get_mbid(self): """Returns the MusicBrainz ID of this artist.""" - doc = self._request("artist.getInfo", True) + doc = self._request(self.ws_prefix + ".getInfo", True) return _extract(doc, "mbid") @@ -1915,19 +1915,20 @@ class Artist(_BaseObject, _Taggable): return self.listener_count else: self.listener_count = _number(_extract( - self._request("artist.getInfo", True), "listeners")) + self._request(self.ws_prefix + ".getInfo", True), "listeners")) return self.listener_count def is_streamable(self): """Returns True if the artist is streamable.""" return bool(_number(_extract( - self._request("artist.getInfo", True), "streamable"))) + self._request(self.ws_prefix + ".getInfo", True), "streamable"))) def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" - return _extract(self._request("artist.getInfo", True), "published") + return _extract( + self._request(self.ws_prefix + ".getInfo", True), "published") def get_bio_summary(self, language=None): """Returns the summary of the artist's biography.""" @@ -1939,7 +1940,7 @@ class Artist(_BaseObject, _Taggable): params = None return self._extract_cdata_from_request( - "artist.getInfo", "summary", params) + self.ws_prefix + ".getInfo", "summary", params) def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" @@ -1951,12 +1952,12 @@ class Artist(_BaseObject, _Taggable): params = None return self._extract_cdata_from_request( - "artist.getInfo", "content", params) + self.ws_prefix + ".getInfo", "content", params) def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" - doc = self._request('artist.getEvents', True) + doc = self._request(self.ws_prefix + '.getEvents', True) return _extract_events_from_doc(doc, self.network) @@ -1967,7 +1968,7 @@ class Artist(_BaseObject, _Taggable): if limit: params['limit'] = limit - doc = self._request('artist.getSimilar', True, params) + doc = self._request(self.ws_prefix + '.getSimilar', True, params) names = _extract_all(doc, "name") matches = _extract_all(doc, "match") @@ -2022,36 +2023,11 @@ class Artist(_BaseObject, _Taggable): def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): """ - Returns a sequence of Image objects - if limit is None it will return all - order can be IMAGES_ORDER_POPULARITY or IMAGES_ORDER_DATE. - - If limit==None, it will try to pull all the available data. + The artist.getImages method has been deprecated by Last.fm. """ - - images = [] - - params = self._get_params() - params["order"] = order - nodes = _collect_nodes(limit, self, "artist.getImages", True, params) - for e in nodes: - if _extract(e, "name"): - user = User(_extract(e, "name"), self.network) - else: - user = None - - images.append( - Image( - _extract(e, "title"), - _extract(e, "url"), - _extract(e, "dateadded"), - _extract(e, "format"), - user, - ImageSizes(*_extract_all(e, "size")), - (_extract(e, "thumbsup"), _extract(e, "thumbsdown")) - ) - ) - return images + raise WSError( + self.network, "27", + "The artist.getImages method has been deprecated by Last.fm.") def shout(self, message): """ @@ -2530,7 +2506,7 @@ class Library(_BaseObject): params["artist"] = album.get_artist().get_name() params["album"] = album.get_name() - self._request("library.removeAlbum", False, params) + self._request(self.ws_prefix + ".removeAlbum", False, params) def add_artist(self, artist): """Add an artist to this library.""" @@ -2541,7 +2517,7 @@ class Library(_BaseObject): else: params["artist"] = artist.get_name() - self._request("library.addArtist", False, params) + self._request(self.ws_prefix + ".addArtist", False, params) def remove_artist(self, artist): """Remove an artist from this library.""" @@ -2552,7 +2528,7 @@ class Library(_BaseObject): else: params["artist"] = artist.get_name() - self._request("library.removeArtist", False, params) + self._request(self.ws_prefix + ".removeArtist", False, params) def add_track(self, track): """Add a track to this library.""" @@ -2560,7 +2536,7 @@ class Library(_BaseObject): params = self._get_params() params["track"] = track.get_title() - self._request("library.addTrack", False, params) + self._request(self.ws_prefix + ".addTrack", False, params) def get_albums(self, artist=None, limit=50, cacheable=True): """ @@ -2576,7 +2552,11 @@ class Library(_BaseObject): seq = [] for node in _collect_nodes( - limit, self, "library.getAlbums", cacheable, params): + limit, + self, + self.ws_prefix + ".getAlbums", + cacheable, + params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) @@ -2595,7 +2575,10 @@ class Library(_BaseObject): seq = [] for node in _collect_nodes( - limit, self, "library.getArtists", cacheable): + limit, + self, + self.ws_prefix + ".getArtists", + cacheable): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) @@ -2620,7 +2603,11 @@ class Library(_BaseObject): seq = [] for node in _collect_nodes( - limit, self, "library.getTracks", cacheable, params): + limit, + self, + self.ws_prefix + ".getTracks", + cacheable, + params): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) @@ -2644,7 +2631,7 @@ class Library(_BaseObject): params["track"] = title params["timestamp"] = timestamp - self._request("library.removeScrobble", False, params) + self._request(self.ws_prefix + ".removeScrobble", False, params) class Playlist(_BaseObject): @@ -2684,7 +2671,7 @@ class Playlist(_BaseObject): return {'user': self.user.get_name(), 'playlistID': self.get_id()} def get_id(self): - """Returns the playlist id.""" + """Returns the playlist ID.""" return self.id @@ -2815,20 +2802,21 @@ class Tag(_BaseObject): return self.get_name().lower() != other.get_name().lower() def _get_params(self): - return {'tag': self.get_name()} + return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized=False): """Returns the name of the tag. """ if properly_capitalized: - self.name = _extract(self._request("tag.getInfo", True), "name") + self.name = _extract( + self._request(self.ws_prefix + ".getInfo", True), "name") return self.name def get_similar(self): """Returns the tags similar to this one, ordered by similarity. """ - doc = self._request('tag.getSimilar', True) + doc = self._request(self.ws_prefix + '.getSimilar', True) seq = [] names = _extract_all(doc, 'name') @@ -2840,7 +2828,7 @@ class Tag(_BaseObject): def get_top_albums(self): """Retuns a list of the top albums.""" - doc = self._request('tag.getTopAlbums', True) + doc = self._request(self.ws_prefix + '.getTopAlbums', True) seq = [] @@ -2865,7 +2853,7 @@ class Tag(_BaseObject): def get_top_artists(self): """Returns a sequence of the most played artists.""" - doc = self._request('tag.getTopArtists', True) + doc = self._request(self.ws_prefix + '.getTopArtists', True) seq = [] for node in doc.getElementsByTagName("artist"): @@ -3123,10 +3111,10 @@ class Group(_BaseObject): __hash__ = _BaseObject.__hash__ - def __init__(self, group_name, network): + def __init__(self, name, network): _BaseObject.__init__(self, network, 'group') - self.name = group_name + self.name = name def __repr__(self): return "pylast.Group(%s, %s)" % (repr(self.name), repr(self.network)) @@ -3222,9 +3210,9 @@ class XSPF(_BaseObject): doc = self._request('playlist.fetch', True) seq = [] - for n in doc.getElementsByTagName('track'): - title = _extract(n, 'title') - artist = _extract(n, 'creator') + for node in doc.getElementsByTagName('track'): + title = _extract(node, 'title') + artist = _extract(node, 'creator') seq.append(Track(artist, title, self.network)) @@ -3388,12 +3376,12 @@ class User(_BaseObject): """ seq = [] - for n in _collect_nodes( + for node in _collect_nodes( limit, self, self.ws_prefix + ".getPastEvents", cacheable): - seq.append(Event(_extract(n, "id"), self.network)) + seq.append(Event(_extract(node, "id"), self.network)) return seq @@ -3567,10 +3555,10 @@ class User(_BaseObject): doc = self._request(self.ws_prefix + '.getTopAlbums', True, params) seq = [] - for album in doc.getElementsByTagName('album'): - name = _extract(album, 'name') - artist = _extract(album, 'name', 1) - playcount = _extract(album, "playcount") + for node in doc.getElementsByTagName('album'): + name = _extract(node, 'name') + artist = _extract(node, 'name', 1) + playcount = _extract(node, "playcount") seq.append(TopItem(Album(artist, name, self.network), playcount)) From 5d6268075348d71c1466044a8179aac1894b15dd Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 10:23:10 +0200 Subject: [PATCH 108/768] Fix a test. Asked for a New York gig and got a Brooklyn one. Let's try London instead. --- test_pylast.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index d5e53a6..a01f4d4 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -848,13 +848,13 @@ class TestPyLast(unittest.TestCase): # Arrange # Act events = self.network.get_geo_events( - lat=40.67, long=-73.94, distance=5, limit=1) + lat=51.52742783719291, long=-0.0860444083809, distance=5, limit=1) # Assert self.assertEqual(len(events), 1) event = events[0] self.assertEqual(type(event), pylast.Event) - self.assertEqual(event.get_venue().location['city'], "New York") + self.assertEqual(event.get_venue().location['city'], "London") def test_geo_get_events_festival(self): # Arrange @@ -1290,7 +1290,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") - test = "" + test = "test_geo_get_events_in_latlong" if test is not None and len(test): suite = unittest.TestSuite() From d5b536d51c25eb0ff75b4c2d65c4d3a080c87370 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 10:35:24 +0200 Subject: [PATCH 109/768] Test: Use a unique filename for picking and delete it afterwards. Allows concurrent testing. --- test_pylast.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index a01f4d4..56bd78d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -643,12 +643,14 @@ class TestPyLast(unittest.TestCase): # Arrange import pickle lastfm_user = self.network.get_user(self.username) + filename = str(self.unix_timestamp()) + ".pkl" # Act - with open("lastfm.txt.pkl", "wb") as f: + with open(filename, "wb") as f: pickle.dump(lastfm_user, f) - with open("lastfm.txt.pkl", "rb") as f: + with open(filename, "rb") as f: loaded_user = pickle.load(f) + os.remove(filename) # Assert self.assertEqual(lastfm_user, loaded_user) @@ -1290,7 +1292,7 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': # For quick testing of a single case (eg. test = "test_scrobble") - test = "test_geo_get_events_in_latlong" + test = "" if test is not None and len(test): suite = unittest.TestSuite() From 46b86b57dafaea4cc2d1e983428062a9244ab4d3 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 14:04:37 +0200 Subject: [PATCH 110/768] Create abstract, internal _Opus class for common stuff used by Album and Track --- pylast.py | 251 ++++++++++++++++--------------------------------- test_pylast.py | 43 +++++++++ 2 files changed, 123 insertions(+), 171 deletions(-) diff --git a/pylast.py b/pylast.py index 4f9aacb..6fd8fbd 100644 --- a/pylast.py +++ b/pylast.py @@ -1661,8 +1661,8 @@ class NetworkError(Exception): return "NetworkError: %s" % str(self.underlying_error) -class Album(_BaseObject, _Taggable): - """An album.""" +class _Opus(_BaseObject, _Taggable): + """An album or track.""" artist = None title = None @@ -1670,16 +1670,17 @@ class Album(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, artist, title, network, username=None): + def __init__(self, artist, title, network, ws_prefix, username=None): """ - Create an album instance. + Create an opus instance. # Parameters: * artist: An artist name or an Artist object. - * title: The album title. + * title: The album or track title. + * ws_prefix: 'album' or 'track' """ - _BaseObject.__init__(self, network, 'album') - _Taggable.__init__(self, 'album') + _BaseObject.__init__(self, network, ws_prefix) + _Taggable.__init__(self, ws_prefix) if isinstance(artist, Artist): self.artist = artist @@ -1690,8 +1691,9 @@ class Album(_BaseObject, _Taggable): self.username = username def __repr__(self): - return "pylast.Album(%s, %s, %s)" % ( - repr(self.artist.name), repr(self.title), repr(self.network)) + return "pylast.%s(%s, %s, %s)" % ( + self.ws_prefix.title(), repr(self.artist.name), + repr(self.title), repr(self.network)) @_string_output def __str__(self): @@ -1715,46 +1717,28 @@ class Album(_BaseObject, _Taggable): def _get_params(self): return { 'artist': self.get_artist().get_name(), - self.ws_prefix: self.get_title(), - } + self.ws_prefix: self.get_title()} def get_artist(self): """Returns the associated Artist object.""" return self.artist - def get_title(self): - """Returns the album title.""" + def get_title(self, properly_capitalized=False): + """Returns the artist or track title.""" + if properly_capitalized: + self.title = _extract( + self._request(self.ws_prefix + ".getInfo", True), "name") return self.title - def get_name(self): - """Returns the album title (alias to Album.get_title).""" + def get_name(self, properly_capitalized=False): + """Returns the album or track title (alias to get_title()).""" - return self.get_title() - - def get_release_date(self): - """Returns the release date of the album.""" - - return _extract(self._request( - self.ws_prefix + ".getInfo", cacheable=True), "releasedate") - - def get_cover_image(self, size=COVER_EXTRA_LARGE): - """ - Returns a uri to the cover image - size can be one of: - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - return _extract_all( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] + return self.get_title(properly_capitalized) def get_id(self): - """Returns the ID""" + """Returns the ID on the network.""" return _extract( self._request(self.ws_prefix + ".getInfo", cacheable=True), "id") @@ -1784,6 +1768,41 @@ class Album(_BaseObject, _Taggable): self._request( self.ws_prefix + ".getInfo", cacheable=True), "listeners")) + def get_mbid(self): + """Returns the MusicBrainz ID of the album or track.""" + + return _extract( + self._request(self.ws_prefix + ".getInfo", cacheable=True), "mbid") + + +class Album(_Opus): + """An album.""" + + __hash__ = _Opus.__hash__ + + def __init__(self, artist, title, network, username=None): + super(Album, self).__init__(artist, title, network, "album", username) + + def get_release_date(self): + """Returns the release date of the album.""" + + return _extract(self._request( + self.ws_prefix + ".getInfo", cacheable=True), "releasedate") + + def get_cover_image(self, size=COVER_EXTRA_LARGE): + """ + Returns a uri to the cover image + size can be one of: + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ + + return _extract_all( + self._request( + self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] + def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -1791,14 +1810,8 @@ class Album(_BaseObject, _Taggable): return XSPF(uri, self.network).get_tracks() - def get_mbid(self): - """Returns the MusicBrainz id of the album.""" - - return _extract( - self._request(self.ws_prefix + ".getInfo", cacheable=True), "mbid") - def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the album page on the network. + """Returns the URL of the album or track page on the network. # Parameters: * domain_name str: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1816,10 +1829,11 @@ class Album(_BaseObject, _Taggable): """ artist = _url_safe(self.get_artist().get_name()) - album = _url_safe(self.get_title()) + title = _url_safe(self.get_title()) return self.network._get_url( - domain_name, "album") % {'artist': artist, self.ws_prefix: album} + domain_name, self.ws_prefix) % { + 'artist': artist, 'album': title} class Artist(_BaseObject, _Taggable): @@ -2886,85 +2900,13 @@ class Tag(_BaseObject): return self.network._get_url(domain_name, "tag") % {'name': name} -class Track(_BaseObject, _Taggable): +class Track(_Opus): """A Last.fm track.""" - artist = None - title = None - username = None - - __hash__ = _BaseObject.__hash__ + __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None): - """ - Create a track instance. - # Parameters: - * artist: An artist name or an Artist object. - * title: The artist title. - """ - _BaseObject.__init__(self, network, 'track') - _Taggable.__init__(self, 'track') - - if isinstance(artist, Artist): - self.artist = artist - else: - self.artist = Artist(artist, self.network) - - self.title = title - self.username = username - - def __repr__(self): - return "pylast.Track(%s, %s, %s)" % ( - repr(self.artist.name), repr(self.title), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_artist().get_name() + ' - ' + self.get_title() - - def __eq__(self, other): - a = self.get_title().lower() - b = other.get_title().lower() - c = self.get_artist().get_name().lower() - d = other.get_artist().get_name().lower() - return (a == b) and (c == d) - - def __ne__(self, other): - a = self.get_title().lower() - b = other.get_title().lower() - c = self.get_artist().get_name().lower() - d = other.get_artist().get_name().lower() - return (a != b) or (c != d) - - def _get_params(self): - return { - 'artist': self.get_artist().get_name(), - self.ws_prefix: self.get_title()} - - def get_artist(self): - """Returns the associated Artist object.""" - - return self.artist - - def get_title(self, properly_capitalized=False): - """Returns the track title.""" - - if properly_capitalized: - self.title = _extract( - self._request(self.ws_prefix + ".getInfo", True), "name") - - return self.title - - def get_name(self, properly_capitalized=False): - """Returns the track title (alias to Track.get_title).""" - - return self.get_title(properly_capitalized) - - def get_id(self): - """Returns the track id on the network.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - return _extract(doc, "id") + super(Track, self).__init__(artist, title, network, "track", username) def get_duration(self): """Returns the track duration.""" @@ -2973,40 +2915,6 @@ class Track(_BaseObject, _Taggable): return _number(_extract(doc, "duration")) - def get_mbid(self): - """Returns the MusicBrainz ID of this track.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - return _extract(doc, "mbid") - - def get_listener_count(self): - """Returns the listener count.""" - - if hasattr(self, "listener_count"): - return self.listener_count - else: - doc = self._request(self.ws_prefix + ".getInfo", True) - self.listener_count = _number(_extract(doc, "listeners")) - return self.listener_count - - def get_playcount(self): - """Returns the play count.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - return _number(_extract(doc, "playcount")) - - def get_userplaycount(self): - """Returns the number of plays by a given username""" - - if not self.username: return - - params = self._get_params() - params['username'] = self.username - - doc = self._request(self.ws_prefix + ".getInfo", True, params) - return _number(_extract(doc, "userplaycount")) - def get_userloved(self): """Whether the user loved this track""" @@ -3080,27 +2988,28 @@ class Track(_BaseObject, _Taggable): return seq def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the track page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE + """Returns the URL of the album or track page on the network. + # Parameters: + * domain_name str: The network's language domain. Possible values: + o DOMAIN_ENGLISH + o DOMAIN_GERMAN + o DOMAIN_SPANISH + o DOMAIN_FRENCH + o DOMAIN_ITALIAN + o DOMAIN_POLISH + o DOMAIN_PORTUGUESE + o DOMAIN_SWEDISH + o DOMAIN_TURKISH + o DOMAIN_RUSSIAN + o DOMAIN_JAPANESE + o DOMAIN_CHINESE """ artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) - return self.network._get_url(domain_name, "track") % { - 'domain': self.network._get_language_domain(domain_name), + return self.network._get_url( + domain_name, self.ws_prefix) % { 'artist': artist, 'title': title} diff --git a/test_pylast.py b/test_pylast.py index 56bd78d..765b9cd 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1288,6 +1288,49 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + def test_album_data(self): + # Arrange + thing = self.network.get_album("Test Artist", "Test Album") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url() + + # Assert + self.assertEqual(stringed, "Test Artist - Test Album") + self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "http://www.last.fm/music/test%2bartist/test%2balbum", url) + + def test_track_data(self): + # Arrange + thing = self.network.get_track("Test Artist", "Test Title") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url(pylast.DOMAIN_FRENCH) + + # Assert + self.assertEqual(stringed, "Test Artist - Test Title") + self.assertIn("pylast.Track('Test Artist', 'Test Title',", repr) + self.assertEqual(title, "Test Title") + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "http://www.lastfm.fr/music/test%2bartist/_/test%2btitle", url) + if __name__ == '__main__': From 4c94c8e53f6d5e60bc6701c32995002e900f9dc5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 14:44:19 +0200 Subject: [PATCH 111/768] Reinstate get_wiki_published_date(), get_wiki_summary() and get_wiki_content() as they may already be used by third parties. Add command-line options to the test. --- README.md | 12 +++++++++++- pylast.py | 23 ++++++++++++++++++++++- test_pylast.py | 43 ++++++++++++++++++++++++++++++++----------- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9e47136..9e5c942 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,22 @@ export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` -Then: +To run all: ``` pip install pyyaml ./test_pylast.py ``` +Or run just one: +``` +./test_pylast.py -1 test_scrobble +``` + +Or all those tests matching a term: +``` +./test_pylast.py -m geo +``` + To run with coverage: ``` pip install coverage diff --git a/pylast.py b/pylast.py index 6fd8fbd..2fc2efc 100644 --- a/pylast.py +++ b/pylast.py @@ -1449,10 +1449,31 @@ class _BaseObject(object): self._request(self.ws_prefix + '.share', False, params) - def get_wiki(self, section): + def get_wiki_published_date(self): """ Returns the summary of the wiki. Only for Album/Track. + """ + return self.get_wiki("published") + + def get_wiki_summary(self): + """ + Returns the summary of the wiki. + Only for Album/Track. + """ + return self.get_wiki("summary") + + def get_wiki_content(self): + """ + Returns the summary of the wiki. + Only for Album/Track. + """ + return self.get_wiki("content") + + def get_wiki(self, section): + """ + Returns a section of the wiki. + Only for Album/Track. section can be "content", "summary" or "published" (for published date) """ diff --git a/test_pylast.py b/test_pylast.py index 765b9cd..d93b2f3 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +import argparse import os from random import choice import time @@ -682,18 +683,18 @@ class TestPyLast(unittest.TestCase): album = pylast.Album("Test Artist", "Test Album", self.network) # Act - wiki = album.get_wiki("content") + wiki = album.get_wiki_content() # Assert self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_album_wiki_published(self): + def test_album_wiki_published_date(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) # Act - wiki = album.get_wiki("published") + wiki = album.get_wiki_published_date() # Assert self.assertIsNotNone(wiki) @@ -704,7 +705,7 @@ class TestPyLast(unittest.TestCase): album = pylast.Album("Test Artist", "Test Album", self.network) # Act - wiki = album.get_wiki("summary") + wiki = album.get_wiki_summary() # Assert self.assertIsNotNone(wiki) @@ -715,7 +716,7 @@ class TestPyLast(unittest.TestCase): track = pylast.Track("Test Artist", "Test Title", self.network) # Act - wiki = track.get_wiki("content") + wiki = track.get_wiki_content() # Assert self.assertIsNotNone(wiki) @@ -726,7 +727,7 @@ class TestPyLast(unittest.TestCase): track = pylast.Track("Test Artist", "Test Title", self.network) # Act - wiki = track.get_wiki("summary") + wiki = track.get_wiki_summary() # Assert self.assertIsNotNone(wiki) @@ -1333,15 +1334,35 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': + parser = argparse.ArgumentParser( + description = "Integration (not unit) tests for pylast.py", + formatter_class = argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-1', '--single', + help="Run a single test") + parser.add_argument('-m', '--matching', + help="Run tests with this in the name") + args = parser.parse_args() - # For quick testing of a single case (eg. test = "test_scrobble") - test = "" - - if test is not None and len(test): + if args.single: suite = unittest.TestSuite() - suite.addTest(TestPyLast(test)) + suite.addTest(TestPyLast(args.single)) unittest.TextTestRunner().run(suite) + + elif args.matching: + suite = unittest.TestSuite() + + import inspect + methods = inspect.getmembers(TestPyLast, predicate=inspect.ismethod) + + tests = [] + for method, _ in methods: + if method.startswith("test_") and args.matching in method: + print method + suite.addTest(TestPyLast(method)) + + unittest.TextTestRunner().run(suite) + else: unittest.main() From 4c4e3bfbba58d6118765da7933817fc126be984d Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 16:01:54 +0200 Subject: [PATCH 112/768] Move duplication into get_bio(), keeping existing get_bio_XXX() to call it --- pylast.py | 43 ++++++++++++++++++++----------------------- test_pylast.py | 23 ++++++++++++++++++----- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/pylast.py b/pylast.py index 2fc2efc..bc351c2 100644 --- a/pylast.py +++ b/pylast.py @@ -1474,8 +1474,8 @@ class _BaseObject(object): """ Returns a section of the wiki. Only for Album/Track. - section can be "content", "summary" or "published" - (for published date) + section can be "content", "summary" or + "published" (for published date) """ doc = self._request(self.ws_prefix + ".getInfo", True) @@ -1959,35 +1959,32 @@ class Artist(_BaseObject, _Taggable): return bool(_number(_extract( self._request(self.ws_prefix + ".getInfo", True), "streamable"))) + def get_bio(self, section, language=None): + """ + Returns a section of the bio. + section can be "content", "summary" or + "published" (for published date) + """ + if language: + params = self._get_params() + params["lang"] = language + else: + params = None + + return self._extract_cdata_from_request( + self.ws_prefix + ".getInfo", section, params) + def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" - - return _extract( - self._request(self.ws_prefix + ".getInfo", True), "published") + return self.get_bio("published") def get_bio_summary(self, language=None): """Returns the summary of the artist's biography.""" - - if language: - params = self._get_params() - params["lang"] = language - else: - params = None - - return self._extract_cdata_from_request( - self.ws_prefix + ".getInfo", "summary", params) + return self.get_bio("summary", language) def get_bio_content(self, language=None): """Returns the content of the artist's biography.""" - - if language: - params = self._get_params() - params["lang"] = language - else: - params = None - - return self._extract_cdata_from_request( - self.ws_prefix + ".getInfo", "content", params) + return self.get_bio("content", language) def get_upcoming_events(self): """Returns a list of the upcoming Events for this artist.""" diff --git a/test_pylast.py b/test_pylast.py index d93b2f3..513ceda 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -656,6 +656,17 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(lastfm_user, loaded_user) + def test_bio_published_date(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_published_date() + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + def test_bio_content(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -1335,11 +1346,13 @@ class TestPyLast(unittest.TestCase): if __name__ == '__main__': parser = argparse.ArgumentParser( - description = "Integration (not unit) tests for pylast.py", - formatter_class = argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument('-1', '--single', + description="Integration (not unit) tests for pylast.py", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument( + '-1', '--single', help="Run a single test") - parser.add_argument('-m', '--matching', + parser.add_argument( + '-m', '--matching', help="Run tests with this in the name") args = parser.parse_args() @@ -1358,7 +1371,7 @@ if __name__ == '__main__': tests = [] for method, _ in methods: if method.startswith("test_") and args.matching in method: - print method + print(method) suite.addTest(TestPyLast(method)) unittest.TextTestRunner().run(suite) From c3d99385ff3376bb0135299e92f1dc4d42a2957c Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 7 Mar 2014 20:22:14 +0200 Subject: [PATCH 113/768] Re-add get_weekly_album_charts(), get_weekly_artist_charts() and get_weekly_charts() helper functions --- pylast.py | 30 +++++++++++++++++++++++++++--- test_pylast.py | 6 +++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/pylast.py b/pylast.py index bc351c2..941bde9 100644 --- a/pylast.py +++ b/pylast.py @@ -1368,14 +1368,38 @@ class _BaseObject(object): return seq - def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): + def get_weekly_album_charts(self, from_date=None, to_date=None): + """ + Returns the weekly album charts for the week starting from the + from_date value to the to_date value. + Only for Group or User. + """ + return self.get_weekly_charts("album", from_date, to_date) + + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + Only for Group, Tag or User. + """ + return self.get_weekly_charts("artist", from_date, to_date) + + def get_weekly_track_charts(self, from_date=None, to_date=None): """ Returns the weekly track charts for the week starting from the from_date value to the to_date value. - chart_kind should be one of "album", "track" + Only for Group or User. + """ + return self.get_weekly_charts("track", from_date, to_date) + + def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): + """ + Returns the weekly charts for the week starting from the + from_date value to the to_date value. + chart_kind should be one of "album", "artist" or "track" """ method = ".getWeekly" + chart_kind.title() + "Chart" - chart_type = eval(chart_kind.title()) + chart_type = eval(chart_kind.title()) # string to type params = self._get_params() if from_date and to_date: diff --git a/test_pylast.py b/test_pylast.py index 513ceda..1c96345 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1179,10 +1179,10 @@ class TestPyLast(unittest.TestCase): (from_date, to_date) = date # Act - artist_chart = thing.get_weekly_charts("artist", from_date, to_date) + artist_chart = thing.get_weekly_artist_charts(from_date, to_date) if type(thing) is not pylast.Tag: - album_chart = thing.get_weekly_charts("album", from_date, to_date) - track_chart = thing.get_weekly_charts("track", from_date, to_date) + album_chart = thing.get_weekly_album_charts(from_date, to_date) + track_chart = thing.get_weekly_track_charts(from_date, to_date) # Assert self.helper_assert_chart(artist_chart, pylast.Artist) From 2235899c98cec270213c00d99d1595dcf48bd6b8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 00:53:31 +0200 Subject: [PATCH 114/768] Remove duplication into _extract_top_artists(doc, network) --- pylast.py | 76 ++++++++++++++++++++------------------------------ test_pylast.py | 30 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/pylast.py b/pylast.py index 941bde9..2e7e9b7 100644 --- a/pylast.py +++ b/pylast.py @@ -363,13 +363,7 @@ class _Network(object): doc = _Request(self, "chart.getTopArtists", params).execute(cacheable) - seq = [] - for node in doc.getElementsByTagName("artist"): - artist = Artist(_extract(node, "name"), self) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(artist, weight)) - - return seq + return _extract_top_artists(doc, self) def get_top_tracks(self, limit=None, cacheable=True): """Returns the most played tracks as a sequence of TopItem objects.""" @@ -497,16 +491,7 @@ class _Network(object): doc = _Request(self, "geo.getTopArtists", params).execute(cacheable) - artists = doc.getElementsByTagName("artist") - seq = [] - - for artist in artists: - name = _extract(artist, "name") - listeners = _extract(artist, "listeners") - - seq.append(TopItem(Artist(name, self), listeners)) - - return seq + return _extract_top_artists(doc, self) def get_geo_top_tracks( self, country, location=None, limit=None, cacheable=True): @@ -2304,19 +2289,15 @@ class Country(_BaseObject): return self.name - def get_top_artists(self): + def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request('geo.getTopArtists', True) + doc = self._request('geo.getTopArtists', cacheable, params) - seq = [] - for node in doc.getElementsByTagName("artist"): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") - - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq + return _extract_top_artists(doc, self) def get_top_tracks(self, limit=None, cacheable=True): """Returns a sequence of the most played tracks""" @@ -2887,7 +2868,6 @@ class Tag(_BaseObject): doc = self._request(self.ws_prefix + '.getTopAlbums', True) seq = [] - for node in doc.getElementsByTagName("album"): name = _extract(node, "name") artist = _extract(node, "name", 1) @@ -2906,19 +2886,17 @@ class Tag(_BaseObject): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def get_top_artists(self): + def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" - doc = self._request(self.ws_prefix + '.getTopArtists', True) + params = self._get_params() + if limit: + params['limit'] = limit - seq = [] - for node in doc.getElementsByTagName("artist"): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") + doc = self._request( + self.ws_prefix + '.getTopArtists', cacheable, params) - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq + return _extract_top_artists(doc, self.network) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the tag page on the network. @@ -3515,7 +3493,7 @@ class User(_BaseObject): return seq - def get_top_artists(self, period=PERIOD_OVERALL): + def get_top_artists(self, period=PERIOD_OVERALL, limit=None): """Returns the top artists played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3527,17 +3505,11 @@ class User(_BaseObject): params = self._get_params() params['period'] = period + if limit: params["limit"] = limit doc = self._request(self.ws_prefix + '.getTopArtists', True, params) - seq = [] - for node in doc.getElementsByTagName('artist'): - name = _extract(node, 'name') - playcount = _extract(node, "playcount") - - seq.append(TopItem(Artist(name, self.network), playcount)) - - return seq + return _extract_top_artists(doc, self.network) def get_top_tags(self, limit=None, cacheable=True): """ @@ -4073,6 +4045,18 @@ def _extract_all(node, name, limit_count=None): return seq +def _extract_top_artists(doc, network): + # TODO Maybe include the _request here too? + seq = [] + for node in doc.getElementsByTagName("artist"): + name = _extract(node, "name") + playcount = _extract(node, "playcount") + + seq.append(TopItem(Artist(name, network), playcount)) + + return seq + + def _extract_events_from_doc(doc, network): events = [] for node in doc.getElementsByTagName("event"): diff --git a/test_pylast.py b/test_pylast.py index 1c96345..b3fac04 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1343,6 +1343,36 @@ class TestPyLast(unittest.TestCase): self.assertEqual( "http://www.lastfm.fr/music/test%2bartist/_/test%2btitle", url) + def test_tag_top_artists(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + artists = tag.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_country_top_artists(self): + # Arrange + country = self.network.get_country("Ukraine") + + # Act + artists = country.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_user_top_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + artists = lastfm_user.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + if __name__ == '__main__': parser = argparse.ArgumentParser( From 2254430b39fc1eb85adccbd5e7523796dd1b8c0a Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 01:13:08 +0200 Subject: [PATCH 115/768] Remove duplication into _extract_top_albums(doc, network) --- pylast.py | 48 ++++++++++++++++++++++++++---------------------- test_pylast.py | 20 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/pylast.py b/pylast.py index 2e7e9b7..6659d6e 100644 --- a/pylast.py +++ b/pylast.py @@ -2862,20 +2862,16 @@ class Tag(_BaseObject): return seq - def get_top_albums(self): + def get_top_albums(self, limit=None, cacheable=True): """Retuns a list of the top albums.""" + params = self._get_params() + if limit: + params['limit'] = limit - doc = self._request(self.ws_prefix + '.getTopAlbums', True) + doc = self._request( + self.ws_prefix + '.getTopAlbums', cacheable, params) - seq = [] - for node in doc.getElementsByTagName("album"): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _extract(node, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq + return _extract_top_albums(doc, self.network) def get_top_tracks(self, limit=None, cacheable=True): """Returns a list of the most played Tracks for this tag.""" @@ -3468,7 +3464,8 @@ class User(_BaseObject): return doc.getElementsByTagName( "registered")[0].getAttribute("unixtime") - def get_top_albums(self, period=PERIOD_OVERALL): + def get_top_albums( + self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -3480,18 +3477,12 @@ class User(_BaseObject): params = self._get_params() params['period'] = period + if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getTopAlbums', True, params) + doc = self._request( + self.ws_prefix + '.getTopAlbums', cacheable, params) - seq = [] - for node in doc.getElementsByTagName('album'): - name = _extract(node, 'name') - artist = _extract(node, 'name', 1) - playcount = _extract(node, "playcount") - - seq.append(TopItem(Album(artist, name, self.network), playcount)) - - return seq + return _extract_top_albums(doc, self.network) def get_top_artists(self, period=PERIOD_OVERALL, limit=None): """Returns the top artists played by a user. @@ -4057,6 +4048,19 @@ def _extract_top_artists(doc, network): return seq +def _extract_top_albums(doc, network): + # TODO Maybe include the _request here too? + seq = [] + for node in doc.getElementsByTagName("album"): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _extract(node, "playcount") + + seq.append(TopItem(Album(artist, name, network), playcount)) + + return seq + + def _extract_events_from_doc(doc, network): events = [] for node in doc.getElementsByTagName("event"): diff --git a/test_pylast.py b/test_pylast.py index b3fac04..bd2eef6 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1373,6 +1373,26 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + def test_tag_top_albums(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + albums = tag.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_user_top_albums(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + albums = user.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + if __name__ == '__main__': parser = argparse.ArgumentParser( From bd9f658de82e36fb07939757f21edf2610d85256 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 11:16:41 +0200 Subject: [PATCH 116/768] Add static analysis to the CI build --- .travis.yml | 3 ++- README.md | 8 ++++++-- check.sh | 11 +++++++++++ requirements.txt | 1 - test_requirements.txt | 5 +++++ 5 files changed, 24 insertions(+), 4 deletions(-) create mode 100755 check.sh delete mode 100644 requirements.txt create mode 100644 test_requirements.txt diff --git a/.travis.yml b/.travis.yml index 768a9e6..d3e85d5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,9 @@ python: - '2.7' - '3.3' install: -- pip install -r requirements.txt +- pip install -r test_requirements.txt - pip install coveralls +before_script: ./check.sh script: coverage run --source=pylast ./test_pylast.py after_success: coveralls diff --git a/README.md b/README.md index 9e5c942..dbc04bb 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all: ``` -pip install pyyaml +pip install -r test_requirements.txt ./test_pylast.py ``` @@ -105,9 +105,13 @@ Or all those tests matching a term: To run with coverage: ``` -pip install coverage coverage run --source=pylast ./test_pylast.py coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html ``` + +To perform some static analysis: +``` +./check.sh +``` diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..c9dad2e --- /dev/null +++ b/check.sh @@ -0,0 +1,11 @@ +pyflakes pylast.py +echo --- +pyflakes test_pylast.py +echo --- +pep8 test_pylast.py +echo --- +pep8 pylast.py +echo --- +clonedigger --cpd-output pylast.py +grep "Clones detected" output.html +grep "lines are duplicates" output.html diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c3726e8..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyyaml diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..2517b20 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,5 @@ +clonedigger +coverage +pep8 +pyyaml +pyflakes From a209bce183d24c7b13104bbe9ea00ac3f3dd4784 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 12:03:41 +0200 Subject: [PATCH 117/768] Remove clonedigger from Travis build, it errors. See #90. --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d3e85d5..1e8fdfc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ python: install: - pip install -r test_requirements.txt - pip install coveralls -before_script: ./check.sh script: coverage run --source=pylast ./test_pylast.py after_success: coveralls From d81145cb3396d2858200986a1cd62c614c3d91e0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 12:05:53 +0200 Subject: [PATCH 118/768] Do the other static checks, but not clonedigger --- .travis.yml | 1 + check.sh | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1e8fdfc..d3e85d5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: install: - pip install -r test_requirements.txt - pip install coveralls +before_script: ./check.sh script: coverage run --source=pylast ./test_pylast.py after_success: coveralls diff --git a/check.sh b/check.sh index c9dad2e..7ea637d 100755 --- a/check.sh +++ b/check.sh @@ -5,7 +5,7 @@ echo --- pep8 test_pylast.py echo --- pep8 pylast.py -echo --- -clonedigger --cpd-output pylast.py -grep "Clones detected" output.html -grep "lines are duplicates" output.html +# echo --- +# clonedigger --cpd-output pylast.py +# grep "Clones detected" output.html +# grep "lines are duplicates" output.html From df6cad029825cdc25824bd77d1a60096a7c12d46 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 12:09:25 +0200 Subject: [PATCH 119/768] Do the other static checks, but not clonedigger --- test_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_requirements.txt b/test_requirements.txt index 2517b20..83871a2 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,4 +1,4 @@ -clonedigger +# clonedigger coverage pep8 pyyaml From 09685547f0e32aeb5529020d9fc80657bb8b7ebc Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 12:15:14 +0200 Subject: [PATCH 120/768] Move check.sh to after_script: so Unix exit codes are ignored --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d3e85d5..fea9378 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,10 +5,10 @@ python: install: - pip install -r test_requirements.txt - pip install coveralls -before_script: ./check.sh script: coverage run --source=pylast ./test_pylast.py after_success: coveralls +after_script: ./check.sh env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= From 014b76d118477967286206d8555988ad0b2accf7 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 21:18:44 +0200 Subject: [PATCH 121/768] Jiggle tests for CI so Clone Digger can run on 2.x. It doesn't yet support 3.x and will fail but shouldn't fail the build. --- .travis.yml | 7 ++++++- test_requirements.txt | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index fea9378..4778562 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,12 @@ install: script: coverage run --source=pylast ./test_pylast.py after_success: coveralls -after_script: ./check.sh +after_script: +- ./check.sh +- pip install clonedigger +- clonedigger pylast.py +- grep "Clones detected" output.html +- grep "lines are duplicates" output.html env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= diff --git a/test_requirements.txt b/test_requirements.txt index 83871a2..d304a29 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,4 +1,3 @@ -# clonedigger coverage pep8 pyyaml From 13f642f37517e2c421f150a0a5ea07d1b99ff576 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 8 Mar 2014 22:02:39 +0200 Subject: [PATCH 122/768] Replace assertEqual(type(x), y) with assertIsInstance(x, y) --- test_pylast.py | 68 +++++++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index bd2eef6..18cd691 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -337,7 +337,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(len(tags), 0) - self.assertEqual(type(tags[0]), pylast.TopItem) + self.assertIsInstance(tags[0], pylast.TopItem) def test_album_tags_are_topitems(self): # Arrange @@ -348,7 +348,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(len(tags), 0) - self.assertEqual(type(tags[0]), pylast.TopItem) + self.assertIsInstance(tags[0], pylast.TopItem) def helper_is_thing_hashable(self, thing): # Arrange @@ -372,7 +372,7 @@ class TestPyLast(unittest.TestCase): # Arrange test_artist = self.network.get_artist("Test Artist") artist = test_artist.get_similar(limit=1)[0].item - self.assertEqual(type(artist), pylast.Artist) + self.assertIsInstance(artist, pylast.Artist) # Act/Assert self.helper_is_thing_hashable(artist) @@ -432,7 +432,7 @@ class TestPyLast(unittest.TestCase): # Arrange artist = self.network.get_artist("Test Artist") track = artist.get_top_tracks()[0].item - self.assertEqual(type(track), pylast.Track) + self.assertIsInstance(track, pylast.Track) # Act/Assert self.helper_is_thing_hashable(track) @@ -441,7 +441,7 @@ class TestPyLast(unittest.TestCase): # Arrange artist = self.network.get_artist("Test Artist") user = artist.get_top_fans(limit=1)[0].item - self.assertEqual(type(user), pylast.User) + self.assertIsInstance(user, pylast.User) # Act/Assert self.helper_is_thing_hashable(user) @@ -855,7 +855,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(events), 1) event = events[0] - self.assertEqual(type(event), pylast.Event) + self.assertIsInstance(event, pylast.Event) self.assertEqual(event.get_venue().location['city'], "London") def test_geo_get_events_in_latlong(self): @@ -867,7 +867,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(events), 1) event = events[0] - self.assertEqual(type(event), pylast.Event) + self.assertIsInstance(event, pylast.Event) self.assertEqual(event.get_venue().location['city'], "London") def test_geo_get_events_festival(self): @@ -879,13 +879,13 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(events), 1) event = events[0] - self.assertEqual(type(event), pylast.Event) + self.assertIsInstance(event, pylast.Event) self.assertEqual(event.get_venue().location['city'], "Reading") def helper_dates_valid(self, dates): # Assert self.assertGreaterEqual(len(dates), 1) - self.assertEqual(type(dates[0]), tuple) + self.assertIsInstance(dates[0], tuple) (start, end) = dates[0] self.assertLess(start, end) @@ -911,8 +911,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(chart), 1) - self.assertEqual(type(chart[0]), pylast.TopItem) - self.assertEqual(type(chart[0].item), expected_type) + self.assertIsInstance(chart[0], pylast.TopItem) + self.assertIsInstance(chart[0].item, expected_type) def test_get_metro_artist_chart(self): # Arrange/Act/Assert @@ -947,7 +947,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(metros), 1) - self.assertEqual(type(metros[0]), pylast.Metro) + self.assertIsInstance(metros[0], pylast.Metro) self.assertEqual(metros[0].get_country(), "Poland") def test_geo_get_top_artists(self): @@ -958,8 +958,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(artists), 1) - self.assertEqual(type(artists[0]), pylast.TopItem) - self.assertEqual(type(artists[0].item), pylast.Artist) + self.assertIsInstance(artists[0], pylast.TopItem) + self.assertIsInstance(artists[0].item, pylast.Artist) def test_geo_get_top_tracks(self): # Arrange @@ -969,8 +969,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(len(tracks), 1) - self.assertEqual(type(tracks[0]), pylast.TopItem) - self.assertEqual(type(tracks[0].item), pylast.Track) + self.assertIsInstance(tracks[0], pylast.TopItem) + self.assertIsInstance(tracks[0].item, pylast.Track) def test_metro_class(self): # Arrange @@ -996,7 +996,7 @@ class TestPyLast(unittest.TestCase): links = self.network.get_album_play_links(albums) # Assert - self.assertEqual(type(links), list) + self.assertIsInstance(links, list) self.assertEqual(len(links), 2) self.assertIn("spotify:album:", links[0]) self.assertIn("spotify:album:", links[1]) @@ -1008,7 +1008,7 @@ class TestPyLast(unittest.TestCase): links = self.network.get_artist_play_links(artists) # Assert - self.assertEqual(type(links), list) + self.assertIsInstance(links, list) self.assertEqual(len(links), 2) self.assertIn("spotify:artist:", links[0]) self.assertIn("spotify:artist:", links[1]) @@ -1023,7 +1023,7 @@ class TestPyLast(unittest.TestCase): links = self.network.get_track_play_links(tracks) # Assert - self.assertEqual(type(links), list) + self.assertIsInstance(links, list) self.assertEqual(len(links), 2) self.assertIn("spotify:track:", links[0]) self.assertIn("spotify:track:", links[1]) @@ -1031,36 +1031,36 @@ class TestPyLast(unittest.TestCase): def helper_at_least_one_thing_in_top_list(self, things, expected_type): # Assert self.assertGreater(len(things), 1) - self.assertEqual(type(things), list) - self.assertEqual(type(things[0]), pylast.TopItem) - self.assertEqual(type(things[0].item), expected_type) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], pylast.TopItem) + self.assertIsInstance(things[0].item, expected_type) def helper_only_one_thing_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 1) - self.assertEqual(type(things), list) - self.assertEqual(type(things[0]), pylast.TopItem) - self.assertEqual(type(things[0].item), expected_type) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], pylast.TopItem) + self.assertIsInstance(things[0].item, expected_type) def helper_two_different_things_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 2) thing1 = things[0] thing2 = things[1] - self.assertEqual(type(thing1), pylast.TopItem) - self.assertEqual(type(thing2), pylast.TopItem) - self.assertEqual(type(thing1.item), expected_type) - self.assertEqual(type(thing2.item), expected_type) + self.assertIsInstance(thing1, pylast.TopItem) + self.assertIsInstance(thing2, pylast.TopItem) + self.assertIsInstance(thing1.item, expected_type) + self.assertIsInstance(thing2.item, expected_type) self.assertNotEqual(thing1, thing2) def helper_two_things_in_list(self, things, expected_type): # Assert self.assertEqual(len(things), 2) - self.assertEqual(type(things), list) + self.assertIsInstance(things, list) thing1 = things[0] thing2 = things[1] - self.assertEqual(type(thing1), expected_type) - self.assertEqual(type(thing2), expected_type) + self.assertIsInstance(thing1, expected_type) + self.assertIsInstance(thing2, expected_type) def test_user_get_top_tags_with_limit(self): # Arrange @@ -1171,8 +1171,8 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNotNone(chart) self.assertGreater(len(chart), 0) - self.assertEqual(type(chart[0]), pylast.TopItem) - self.assertEqual(type(chart[0].item), expected_type) + self.assertIsInstance(chart[0], pylast.TopItem) + self.assertIsInstance(chart[0].item, expected_type) def helper_get_assert_charts(self, thing, date): # Arrange From e09c480980ffe27384616b0f639bcdd015fac8eb Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 11 Mar 2014 17:02:13 +0200 Subject: [PATCH 123/768] Move chart methods out of _BaseObject and into their own _Chartable so they can only be used by relevant classes (Group, Tag, User) --- .gitignore | 1 + check.sh | 2 +- pylast.py | 138 ++++++++++++++++++++++++++++------------------------- 3 files changed, 76 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 66d3a64..23b3b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ htmlcov/ nosetests.xml coverage.xml output.html +output.xml # Translations *.mo diff --git a/check.sh b/check.sh index 7ea637d..0e2c1d2 100755 --- a/check.sh +++ b/check.sh @@ -6,6 +6,6 @@ pep8 test_pylast.py echo --- pep8 pylast.py # echo --- -# clonedigger --cpd-output pylast.py +# clonedigger pylast.py # grep "Clones detected" output.html # grep "lines are duplicates" output.html diff --git a/pylast.py b/pylast.py index 6659d6e..588401f 100644 --- a/pylast.py +++ b/pylast.py @@ -1342,67 +1342,6 @@ class _BaseObject(object): return seq - def get_weekly_chart_dates(self): - """Returns a list of From and To tuples for the available charts.""" - - doc = self._request(self.ws_prefix + ".getWeeklyChartList", True) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append((node.getAttribute("from"), node.getAttribute("to"))) - - return seq - - def get_weekly_album_charts(self, from_date=None, to_date=None): - """ - Returns the weekly album charts for the week starting from the - from_date value to the to_date value. - Only for Group or User. - """ - return self.get_weekly_charts("album", from_date, to_date) - - def get_weekly_artist_charts(self, from_date=None, to_date=None): - """ - Returns the weekly artist charts for the week starting from the - from_date value to the to_date value. - Only for Group, Tag or User. - """ - return self.get_weekly_charts("artist", from_date, to_date) - - def get_weekly_track_charts(self, from_date=None, to_date=None): - """ - Returns the weekly track charts for the week starting from the - from_date value to the to_date value. - Only for Group or User. - """ - return self.get_weekly_charts("track", from_date, to_date) - - def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): - """ - Returns the weekly charts for the week starting from the - from_date value to the to_date value. - chart_kind should be one of "album", "artist" or "track" - """ - method = ".getWeekly" + chart_kind.title() + "Chart" - chart_type = eval(chart_kind.title()) # string to type - - params = self._get_params() - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request( - self.ws_prefix + method, True, params) - - seq = [] - for node in doc.getElementsByTagName(chart_kind.lower()): - item = chart_type( - _extract(node, "artist"), _extract(node, "name"), self.network) - weight = _number(_extract(node, "playcount")) - seq.append(TopItem(item, weight)) - - return seq - def get_top_fans(self, limit=None, cacheable=True): """Returns a list of the Users who played this the most. # Parameters: @@ -1517,6 +1456,74 @@ class _BaseObject(object): return shouts +class _Chartable(object): + """Common functions for classes with charts.""" + + def __init__(self, ws_prefix): + self.ws_prefix = ws_prefix # TODO move to _BaseObject? + + def get_weekly_chart_dates(self): + """Returns a list of From and To tuples for the available charts.""" + + doc = self._request(self.ws_prefix + ".getWeeklyChartList", True) + + seq = [] + for node in doc.getElementsByTagName("chart"): + seq.append((node.getAttribute("from"), node.getAttribute("to"))) + + return seq + + def get_weekly_album_charts(self, from_date=None, to_date=None): + """ + Returns the weekly album charts for the week starting from the + from_date value to the to_date value. + Only for Group or User. + """ + return self.get_weekly_charts("album", from_date, to_date) + + def get_weekly_artist_charts(self, from_date=None, to_date=None): + """ + Returns the weekly artist charts for the week starting from the + from_date value to the to_date value. + Only for Group, Tag or User. + """ + return self.get_weekly_charts("artist", from_date, to_date) + + def get_weekly_track_charts(self, from_date=None, to_date=None): + """ + Returns the weekly track charts for the week starting from the + from_date value to the to_date value. + Only for Group or User. + """ + return self.get_weekly_charts("track", from_date, to_date) + + def get_weekly_charts(self, chart_kind, from_date=None, to_date=None): + """ + Returns the weekly charts for the week starting from the + from_date value to the to_date value. + chart_kind should be one of "album", "artist" or "track" + """ + method = ".getWeekly" + chart_kind.title() + "Chart" + chart_type = eval(chart_kind.title()) # string to type + + params = self._get_params() + if from_date and to_date: + params["from"] = from_date + params["to"] = to_date + + doc = self._request( + self.ws_prefix + method, True, params) + + seq = [] + for node in doc.getElementsByTagName(chart_kind.lower()): + item = chart_type( + _extract(node, "artist"), _extract(node, "name"), self.network) + weight = _number(_extract(node, "playcount")) + seq.append(TopItem(item, weight)) + + return seq + + class _Taggable(object): """Common functions for classes with tags.""" @@ -2813,7 +2820,7 @@ class Playlist(_BaseObject): 'appendix': appendix, "user": self.get_user().get_name()} -class Tag(_BaseObject): +class Tag(_BaseObject, _Chartable): """A Last.fm object tag.""" name = None @@ -2822,6 +2829,7 @@ class Tag(_BaseObject): def __init__(self, name, network): _BaseObject.__init__(self, network, 'tag') + _Chartable.__init__(self, 'tag') self.name = name @@ -3029,7 +3037,7 @@ class Track(_Opus): 'artist': artist, 'title': title} -class Group(_BaseObject): +class Group(_BaseObject, _Chartable): """A Last.fm group.""" name = None @@ -3038,6 +3046,7 @@ class Group(_BaseObject): def __init__(self, name, network): _BaseObject.__init__(self, network, 'group') + _Chartable.__init__(self, 'group') self.name = name @@ -3144,7 +3153,7 @@ class XSPF(_BaseObject): return seq -class User(_BaseObject): +class User(_BaseObject, _Chartable): """A Last.fm user.""" name = None @@ -3153,6 +3162,7 @@ class User(_BaseObject): def __init__(self, user_name, network): _BaseObject.__init__(self, network, 'user') + _Chartable.__init__(self, 'user') self.name = user_name From 181ad7b6c9b78b3c1522e11973359aa0d8277e9b Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 12 Mar 2014 13:16:23 +0200 Subject: [PATCH 124/768] Remove deprecated .has_key() --- .build | 2 +- pylast.py | 8 ++++---- test_pylast.py | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.build b/.build index b6a7d89..98d9bcb 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -16 +17 diff --git a/pylast.py b/pylast.py index 588401f..051007c 100644 --- a/pylast.py +++ b/pylast.py @@ -986,15 +986,15 @@ class _ShelfCacheBackend(object): def __init__(self, file_path=None): self.shelf = shelve.open(file_path) + def __iter__(self): + return iter(self.shelf.keys()) + def get_xml(self, key): return self.shelf[key] def set_xml(self, key, xml_string): self.shelf[key] = xml_string - def has_key(self, key): - return key in self.shelf.keys() - class _Request(object): """Representing an abstract web service operation.""" @@ -1073,7 +1073,7 @@ class _Request(object): def _is_cached(self): """Returns True if the request is already in cache.""" - return self.cache.has_key(self._get_cache_key()) + return self._get_cache_key() in self.cache def _download_response(self): """Returns a response body string from the server.""" diff --git a/test_pylast.py b/test_pylast.py index 18cd691..3a583f5 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1393,6 +1393,21 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) + def test_caching(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + self.network.enable_caching() + shouts1 = user.get_shouts(limit=1, cacheable=True) + shouts2 = user.get_shouts(limit=1, cacheable=True) + + # Assert + self.assertTrue(self.network.is_caching_enabled()) + self.assertEqual(shouts1, shouts2) + self.network.disable_caching() + self.assertFalse(self.network.is_caching_enabled()) + if __name__ == '__main__': parser = argparse.ArgumentParser( From 20b9bdedd2895de18945519b5e9348a2fade692a Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 12 Mar 2014 13:43:41 +0200 Subject: [PATCH 125/768] Python 3 fix: Unicode-objects must be encoded before hashing --- pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 051007c..03c7767 100644 --- a/pylast.py +++ b/pylast.py @@ -1059,7 +1059,7 @@ class _Request(object): if key != "api_sig" and key != "api_key" and key != "sk": cache_key += key + _string(self.params[key]) - return hashlib.sha1(cache_key).hexdigest() + return hashlib.sha1(cache_key.encode("utf-8")).hexdigest() def _get_cached_response(self): """Returns a file object of the cached response.""" From 506bee4fe46dfab42daa6f19857352b9990acc51 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 12 Mar 2014 15:18:24 +0200 Subject: [PATCH 126/768] Python 3 fix: DeprecationWarning: Please use assertRaisesRegex instead --- test_pylast.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 3a583f5..912e8ea 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -5,6 +5,7 @@ Integration (not unit) tests for pylast.py import argparse import os from random import choice +import sys import time import unittest @@ -30,6 +31,16 @@ class TestPyLast(unittest.TestCase): secrets = None + # Based on django/utils/six.py to remove Python 3's + # "DeprecationWarning: Please use assertRaisesRegex instead" + if sys.version_info[0] == 2: + _assertRaisesRegex = "assertRaisesRegexp" + else: + _assertRaisesRegex = "assertRaisesRegex" + + def assertRaisesRegex(self, *args, **kwargs): + return getattr(self, self._assertRaisesRegex)(*args, **kwargs) + def unix_timestamp(self): return int(time.time()) @@ -756,7 +767,7 @@ class TestPyLast(unittest.TestCase): artist = self.network.get_artist("Test Artist") # Act/Assert - with self.assertRaisesRegexp(pylast.WSError, 'deprecated'): + with self.assertRaisesRegex(pylast.WSError, 'deprecated'): artist.get_images() def helper_validate_results(self, a, b, c): From 7019b1b9464847dce580180e6d1c29cc1af73d9e Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 12 Mar 2014 15:32:27 +0200 Subject: [PATCH 127/768] Python 3 fix: DeprecationWarning: Please use assertRaisesRegex instead --- test_pylast.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 912e8ea..1dec516 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -31,15 +31,10 @@ class TestPyLast(unittest.TestCase): secrets = None - # Based on django/utils/six.py to remove Python 3's + # To remove Python 3's # "DeprecationWarning: Please use assertRaisesRegex instead" if sys.version_info[0] == 2: - _assertRaisesRegex = "assertRaisesRegexp" - else: - _assertRaisesRegex = "assertRaisesRegex" - - def assertRaisesRegex(self, *args, **kwargs): - return getattr(self, self._assertRaisesRegex)(*args, **kwargs) + assertRaisesRegex = unittest.TestCase.assertRaisesRegexp def unix_timestamp(self): return int(time.time()) From 862f7fe45cd78050cb14c097cb0ef496e324c5bc Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 17 Mar 2014 22:48:08 +0200 Subject: [PATCH 128/768] Add some Playlist tests --- test_pylast.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index 1dec516..9874c8b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1414,6 +1414,34 @@ class TestPyLast(unittest.TestCase): self.network.disable_caching() self.assertFalse(self.network.is_caching_enabled()) + def test_create_playlist(self): + # Arrange + title = "Test playlist" + description = "Testing" + lastfm_user = self.network.get_user(self.username) + + # Act + playlist = self.network.create_new_playlist(title, description) + + # Assert + self.assertIsInstance(playlist, pylast.Playlist) + self.assertEqual(playlist.get_title(), "Test playlist") + self.assertEqual(playlist.get_description(), "Testing") + self.assertEqual(playlist.get_user(), lastfm_user) + + def test_empty_playlist_unstreamable(self): + # Arrange + title = "Empty playlist" + description = "Unstreamable" + + # Act + playlist = self.network.create_new_playlist(title, description) + + # Assert + self.assertEqual(playlist.get_size(), 0) + self.assertEqual(playlist.get_duration(), 0) + self.assertFalse(playlist.is_streamable()) + if __name__ == '__main__': parser = argparse.ArgumentParser( From 7a3072b52cbfdde1053b30145d3a8dd22dcf234e Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 17 Mar 2014 23:14:51 +0200 Subject: [PATCH 129/768] Add some Playlist tests --- test_pylast.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index 9874c8b..236a155 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1442,6 +1442,42 @@ class TestPyLast(unittest.TestCase): self.assertEqual(playlist.get_duration(), 0) self.assertFalse(playlist.is_streamable()) + def test_big_playlist_is_streamable(self): + # Arrange + # Find a big playlist on Last.fm, eg "top 100 classick rock songs" + user = "kaxior" + id = 10417943 + playlist = pylast.Playlist(user, id, self.network) + self.assertEqual( + playlist.get_url(), + "http://www.last.fm/user/kaxior/library/" + "playlists/67ajb_top_100_classick_rock_songs") + + # Act + # Nothing + + # Assert + self.assertIsInstance(playlist, pylast.Playlist) + self.assertGreaterEqual(playlist.get_size(), 45) + self.assertGreater(playlist.get_duration(), 0) + self.assertTrue(playlist.is_streamable()) + + def test_add_track_to_playlist(self): + # Arrange + title = "One track playlist" + description = "Testing" + playlist = self.network.create_new_playlist(title, description) + track = pylast.Track("Test Artist", "Test Title", self.network) + + # Act + playlist.add_track(track) + + # Assert + self.assertEqual(playlist.get_size(), 1) + self.assertEqual(len(playlist.get_tracks()), 1) + self.assertTrue(playlist.has_track(track)) + + if __name__ == '__main__': parser = argparse.ArgumentParser( From 7fb56b1d28f0213e4e46f85659b7a9a9dbfef145 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 21 Mar 2014 14:58:33 +0200 Subject: [PATCH 130/768] Add some more tests --- test_pylast.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index 236a155..85c12a6 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1477,6 +1477,66 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(playlist.get_tracks()), 1) self.assertTrue(playlist.has_track(track)) + def test_album_mbid(self): + # Arrange + mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" + + # Act + album = self.network.get_album_by_mbid(mbid) + album_mbid = album.get_mbid() + + # Assert + self.assertIsInstance(album, pylast.Album) + self.assertEqual(album.title, "TEST") + self.assertEqual(album_mbid, mbid) + + def test_artist_mbid(self): + # Arrange + mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" + + # Act + artist = self.network.get_artist_by_mbid(mbid) + + # Assert + self.assertIsInstance(artist, pylast.Artist) + self.assertEqual(artist.name, "MusicBrainz Test Artist") + + def test_track_mbid(self): + # Arrange + mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" + + # Act + track = self.network.get_track_by_mbid(mbid) + track_mbid = track.get_mbid() + + # Assert + self.assertIsInstance(track, pylast.Track) + self.assertEqual(track.title, "first") + self.assertEqual(track_mbid, mbid) + + def test_artist_listener_count(self): + # Arrange + artist = self.network.get_artist("Test Artist") + print artist + + # Act + count = artist.get_listener_count() + + # Assert + self.assertIsInstance(count, int) + self.assertGreater(count, 0) + + def test_event_attendees(self): + # Arrange + user = self.network.get_user("RJ") + event = user.get_past_events(limit=1)[0] + + # Act + users = event.get_attendees() + + # Assert + self.assertIsInstance(users, list) + self.assertIsInstance(users[0], pylast.User) if __name__ == '__main__': From 536203fdb8260635a598f11934c9940deb81396c Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 21 Mar 2014 15:20:10 +0200 Subject: [PATCH 131/768] Remove print from test --- test_pylast.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 85c12a6..5cae621 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1517,7 +1517,6 @@ class TestPyLast(unittest.TestCase): def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") - print artist # Act count = artist.get_listener_count() From e28e2ef0d0a5dfd60f197b58275de04304028f83 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 21 Mar 2014 17:39:06 +0200 Subject: [PATCH 132/768] Test failed because a London event was in the 'city' of Camden. Let's try Manchester instead. --- test_pylast.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 5cae621..b3b4da1 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -868,13 +868,13 @@ class TestPyLast(unittest.TestCase): # Arrange # Act events = self.network.get_geo_events( - lat=51.52742783719291, long=-0.0860444083809, distance=5, limit=1) + lat=53.466667, long=-2.233333, distance=5, limit=1) # Assert self.assertEqual(len(events), 1) event = events[0] self.assertIsInstance(event, pylast.Event) - self.assertEqual(event.get_venue().location['city'], "London") + self.assertEqual(event.get_venue().location['city'], "Manchester") def test_geo_get_events_festival(self): # Arrange @@ -1571,6 +1571,6 @@ if __name__ == '__main__': unittest.TextTestRunner().run(suite) else: - unittest.main() + unittest.main(failfast=True) # End of file From adbe8a850098070eb5a7dfe63def86b39f2ce1aa Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 4 Apr 2014 21:53:45 +0300 Subject: [PATCH 133/768] Refactor network names. Add some tag tests. Add stress test option. Add extra Python versions as allowed failures to Travis. --- .travis.yml | 16 +++++++-- pylast.py | 8 +---- test_pylast.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4778562..1b72dac 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,11 @@ language: python python: -- '2.7' -- '3.3' +- "2.6" +- "2.7" +- "3.2" +- "3.3" +- "3.4" +- "pypy" install: - pip install -r test_requirements.txt - pip install coveralls @@ -9,6 +13,7 @@ script: coverage run --source=pylast ./test_pylast.py after_success: coveralls after_script: +- coverage report - ./check.sh - pip install clonedigger - clonedigger pylast.py @@ -20,3 +25,10 @@ env: - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= + +matrix: + allow_failures: + - python: "2.6" + - python: "3.2" + - python: "3.4" + - python: "pypy" diff --git a/pylast.py b/pylast.py index 03c7767..8d82548 100644 --- a/pylast.py +++ b/pylast.py @@ -204,7 +204,7 @@ class _Network(object): """ def __str__(self): - return "The %s Network" % self.name + return "%s Network" % self.name def get_artist(self, artist_name): """ @@ -854,9 +854,6 @@ class LastFMNetwork(_Network): "'%s'" % self.username, "'%s'" % self.password_hash))) - def __str__(self): - return "Last.fm Network" - def get_lastfm_network( api_key="", api_secret="", session_key="", username="", @@ -953,9 +950,6 @@ class LibreFMNetwork(_Network): "'%s'" % self.username, "'%s'" % self.password_hash))) - def __str__(self): - return "Libre.fm Network" - def get_librefm_network( api_key="", api_secret="", session_key="", username="", diff --git a/test_pylast.py b/test_pylast.py index b3b4da1..a2cfc24 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -53,6 +53,7 @@ class TestPyLast(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) + def test_scrobble(self): # Arrange artist = "Test Artist" @@ -1538,6 +1539,84 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(users[0], pylast.User) + def test_tag_artist(self): + # Arrange + artist = self.network.get_artist("Test Artist") +# artist.clear_tags() + + # Act + artist.add_tag("testing") + + # Assert + tags = artist.get_tags() + self.assertGreater(len(tags), 0) + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertTrue(found) + + def test_remove_tag_of_type_text(self): + # Arrange + tag = "testing" # text + artist = self.network.get_artist("Test Artist") + artist.add_tag(tag) + + # Act + artist.remove_tag(tag) + + # Assert + tags = artist.get_tags() + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertFalse(found) + + + def test_remove_tag_of_type_tag(self): + # Arrange + tag = pylast.Tag("testing", self.network) # Tag + artist = self.network.get_artist("Test Artist") + artist.add_tag(tag) + + # Act + artist.remove_tag(tag) + + # Assert + tags = artist.get_tags() + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertFalse(found) + + + def test_remove_tags(self): + # Arrange + tags = ["testing1", "testing2"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + + # Act + artist.remove_tags(tags) + + # Assert + tags = artist.get_tags() + found = False + for tag in tags: + if tag.name == "testing1" or tag.name == "testing2": + found = True + break + self.assertFalse(found) + + + + + if __name__ == '__main__': parser = argparse.ArgumentParser( description="Integration (not unit) tests for pylast.py", @@ -1545,6 +1624,9 @@ if __name__ == '__main__': parser.add_argument( '-1', '--single', help="Run a single test") + parser.add_argument( + '-r', '--repeat', + help="Repeat a single test (100 times) until failure") parser.add_argument( '-m', '--matching', help="Run tests with this in the name") @@ -1556,6 +1638,17 @@ if __name__ == '__main__': suite.addTest(TestPyLast(args.single)) unittest.TextTestRunner().run(suite) + elif args.repeat: + suite = unittest.TestSuite() + + suite.addTest(TestPyLast(args.repeat)) + for i in range(100): + print("Attempt " + str(i+1)) + result = unittest.TextTestRunner().run(suite) + problems = len(result.errors) + len(result.failures) + if problems: + break + elif args.matching: suite = unittest.TestSuite() From e4ccd8b767e810a7c3f07379d298abb83798e7fd Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 5 Apr 2014 00:30:33 +0300 Subject: [PATCH 134/768] More tests. Don't bother with failing Py2.6 or 3.2, they're unsupported. --- .travis.yml | 4 --- test_pylast.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1b72dac..c853ea3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,6 @@ language: python python: -- "2.6" - "2.7" -- "3.2" - "3.3" - "3.4" - "pypy" @@ -28,7 +26,5 @@ env: matrix: allow_failures: - - python: "2.6" - - python: "3.2" - python: "3.4" - python: "pypy" diff --git a/test_pylast.py b/test_pylast.py index a2cfc24..17d5bad 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -237,7 +237,7 @@ class TestPyLast(unittest.TestCase): def test_unlove(self): # Arrange - artist = "Test Artist" + artist = pylast.Artist("Test Artist", self.network) title = "Test Title" track = pylast.Track(artist, title, self.network) lastfm_user = self.network.get_user(self.username) @@ -1594,7 +1594,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) - def test_remove_tags(self): # Arrange tags = ["testing1", "testing2"] @@ -1613,6 +1612,90 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) + def test_set_tags(self): + # Arrange + tags = ["settag1", "settag2"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + artist.add_tags("1more") + tags_before = artist.get_tags() + + # Act + artist.remove_tags(tags) + + # Assert + tags_after = artist.get_tags() + self.assertEqual(len(tags_after), len(tags_before) - 2) + found1, found2 = False, False + for tag in tags: + if tag.name == "settag1": + found1 = True + elif tag.name == "settag2": + found2 = True + self.assertTrue(found1) + self.assertTrue(found2) + + def test_tracks_notequal(self): + # Arrange + track1 = pylast.Track("Test Artist", "Test Title", self.network) + track2 = pylast.Track("Test Artist", "Test Track", self.network) + + # Act + # Assert + self.assertNotEqual(track1, track2) + + def test_track_id(self): + # Arrange + track = pylast.Track("Test Artist", "Test Title", self.network) + + # Act + id = track.get_id() + + # Assert + self.assertEqual(id, "14053327") + + def test_track_title_prop_caps(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + title = track.get_title(properly_capitalized=True) + + # Assert + self.assertEqual(title, "Test Title") + + def test_track_listener_count(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + count = track.get_listener_count() + + # Assert + self.assertGreater(count, 21) + + def test_album_rel_date(self): + # Arrange + album = pylast.Album("Test Artist", "Test Release", self.network) + + # Act + date = album.get_release_date() + + # Assert + self.assertIn("2011", date) + + def test_album_tracks(self): + # Arrange + album = pylast.Album("Test Artist", "Test Release", self.network) + + # Act + tracks = album.get_tracks() + + # Assert + self.assertIsInstance(tracks, list) + self.assertIsInstance(tracks[0], pylast.Track) + self.assertEqual(len(tracks), 4) + From b9d2ca8507b5ac7e490d7889238feeb5c015ba5e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 5 Apr 2014 00:51:18 +0300 Subject: [PATCH 135/768] Fix test --- test_pylast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index 17d5bad..c31871e 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1628,9 +1628,9 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tags_after), len(tags_before) - 2) found1, found2 = False, False for tag in tags: - if tag.name == "settag1": + if tag == "settag1": found1 = True - elif tag.name == "settag2": + elif tag == "settag2": found2 = True self.assertTrue(found1) self.assertTrue(found2) From 3bf73a21ff0674843dfa2cda330ffedd1dc898d2 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 10:55:57 +0300 Subject: [PATCH 136/768] Test set_tags() --- test_pylast.py | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/test_pylast.py b/test_pylast.py index c31871e..f2027fe 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -53,7 +53,6 @@ class TestPyLast(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) - def test_scrobble(self): # Arrange artist = "Test Artist" @@ -1538,7 +1537,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(users, list) self.assertIsInstance(users[0], pylast.User) - def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1575,7 +1573,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) - def test_remove_tag_of_type_tag(self): # Arrange tag = pylast.Tag("testing", self.network) # Tag @@ -1596,25 +1593,7 @@ class TestPyLast(unittest.TestCase): def test_remove_tags(self): # Arrange - tags = ["testing1", "testing2"] - artist = self.network.get_artist("Test Artist") - artist.add_tags(tags) - - # Act - artist.remove_tags(tags) - - # Assert - tags = artist.get_tags() - found = False - for tag in tags: - if tag.name == "testing1" or tag.name == "testing2": - found = True - break - self.assertFalse(found) - - def test_set_tags(self): - # Arrange - tags = ["settag1", "settag2"] + tags = ["removetag1", "removetag2"] artist = self.network.get_artist("Test Artist") artist.add_tags(tags) artist.add_tags("1more") @@ -1627,6 +1606,30 @@ class TestPyLast(unittest.TestCase): tags_after = artist.get_tags() self.assertEqual(len(tags_after), len(tags_before) - 2) found1, found2 = False, False + for tag in tags: + if tag == "removetag1": + found1 = True + elif tag == "removetag2": + found2 = True + self.assertTrue(found1) + self.assertTrue(found2) + + def test_set_tags(self): + # Arrange + tags = ["sometag1", "sometag2"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + tags_before = artist.get_tags() + new_tags = ["settag1", "settag2"] + + # Act + artist.set_tags(new_tags) + + # Assert + tags_after = artist.get_tags() + self.assertNotEqual(tags_before, tags_after) + self.assertEqual(len(tags_after), 2) + found1, found2 = False, False for tag in tags: if tag == "settag1": found1 = True @@ -1697,9 +1700,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tracks), 4) - - - if __name__ == '__main__': parser = argparse.ArgumentParser( description="Integration (not unit) tests for pylast.py", From 1c26ef6d24539f4b68359b9fb992e8578159306d Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 11:41:41 +0300 Subject: [PATCH 137/768] Fix tests --- setup.py | 49 +++++++++++++++++++++++++++---------------------- test_pylast.py | 16 ++++++++-------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/setup.py b/setup.py index 908dd0d..5b62429 100755 --- a/setup.py +++ b/setup.py @@ -3,30 +3,35 @@ from distutils.core import setup import os + + def get_build(): - path = "./.build" + path = "./.build" - if os.path.exists(path): - fp = open(path, "r") - build = eval(fp.read()) - if os.path.exists("./.increase_build"): - build += 1 - fp.close() - else: - build = 1 + if os.path.exists(path): + fp = open(path, "r") + build = eval(fp.read()) + if os.path.exists("./.increase_build"): + build += 1 + fp.close() + else: + build = 1 - fp = open(path, "w") - fp.write(str(build)) - fp.close() + fp = open(path, "w") + fp.write(str(build)) + fp.close() - return str(build) + return str(build) -setup(name = "pylast", - version = "0.6." + get_build(), - author = "Amr Hassan ", - description = "A Python interface to Last.fm (and other API compatible social networks)", - author_email = "amr.hassan@gmail.com", - url = "https://github.com/hugovk/", - py_modules = ("pylast",), - license = "Apache2" - ) +setup( + name="pylast", + version="0.6." + get_build(), + author="Amr Hassan ", + description="A Python interface to Last.fm (and other API compatible social networks)", + author_email="amr.hassan@gmail.com", + url="https://github.com/hugovk/pylast", + py_modules=("pylast",), + license="Apache2" + ) + +# End of file diff --git a/test_pylast.py b/test_pylast.py index f2027fe..6ec8d91 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1606,13 +1606,13 @@ class TestPyLast(unittest.TestCase): tags_after = artist.get_tags() self.assertEqual(len(tags_after), len(tags_before) - 2) found1, found2 = False, False - for tag in tags: - if tag == "removetag1": + for tag in tags_after: + if tag.name == "removetag1": found1 = True - elif tag == "removetag2": + elif tag.name == "removetag2": found2 = True - self.assertTrue(found1) - self.assertTrue(found2) + self.assertFalse(found1) + self.assertFalse(found2) def test_set_tags(self): # Arrange @@ -1630,10 +1630,10 @@ class TestPyLast(unittest.TestCase): self.assertNotEqual(tags_before, tags_after) self.assertEqual(len(tags_after), 2) found1, found2 = False, False - for tag in tags: - if tag == "settag1": + for tag in tags_after: + if tag.name == "settag1": found1 = True - elif tag == "settag2": + elif tag.name == "settag2": found2 = True self.assertTrue(found1) self.assertTrue(found2) From 5f69aa591cbaff4ba16bae6a220f3ad993746b3c Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 11:49:40 +0300 Subject: [PATCH 138/768] Add cacheable parameter to get_neighbours() --- pylast.py | 4 ++-- test_pylast.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 8d82548..cacff7e 100644 --- a/pylast.py +++ b/pylast.py @@ -3281,14 +3281,14 @@ class User(_BaseObject, _Chartable): return seq - def get_neighbours(self, limit=50): + def get_neighbours(self, limit=50, cacheable=True): """Returns a list of the user's friends.""" params = self._get_params() if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getNeighbours', True, params) + doc = self._request(self.ws_prefix + '.getNeighbours', cacheable, params) seq = [] names = _extract_all(doc, 'name') diff --git a/test_pylast.py b/test_pylast.py index 6ec8d91..91a943b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -846,6 +846,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(lastfm_user, "get_friends") self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") + self.helper_validate_cacheable(lastfm_user, "get_neighbours") self.helper_validate_cacheable(lastfm_user, "get_past_events") self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") self.helper_validate_cacheable(lastfm_user, "get_recommended_artists") From 50f1a0ac8f4015a0d0a54f635a98809b5acc15b8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 11:53:01 +0300 Subject: [PATCH 139/768] Sometimes the lastest date range doesn't yet have charts, so get the latest-but-one --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 91a943b..9f0897d 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1204,7 +1204,7 @@ class TestPyLast(unittest.TestCase): self.helper_dates_valid(dates) # Act/Assert - self.helper_get_assert_charts(group, dates[-1]) + self.helper_get_assert_charts(group, dates[-2]) def test_tag_charts(self): # Arrange From 9243e98b94fbf36aebf7e4fc035cf280c032c659 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 12:29:50 +0300 Subject: [PATCH 140/768] Test some tag functions --- pylast.py | 42 ++++++++++++++++++++++++++++-------------- test_pylast.py | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/pylast.py b/pylast.py index cacff7e..66622c6 100644 --- a/pylast.py +++ b/pylast.py @@ -359,7 +359,8 @@ class _Network(object): """Returns the most played artists as a sequence of TopItem objects.""" params = {} - if limit: params["limit"] = limit + if limit: + params["limit"] = limit doc = _Request(self, "chart.getTopArtists", params).execute(cacheable) @@ -369,7 +370,8 @@ class _Network(object): """Returns the most played tracks as a sequence of TopItem objects.""" params = {} - if limit: params["limit"] = limit + if limit: + params["limit"] = limit doc = _Request(self, "chart.getTopTracks", params).execute(cacheable) @@ -462,7 +464,8 @@ class _Network(object): """ params = {} - if country: params["country"] = country + if country: + params["country"] = country doc = _Request(self, "geo.getMetros", params).execute(cacheable) @@ -487,7 +490,8 @@ class _Network(object): """ params = {"country": country} - if limit: params["limit"] = limit + if limit: + params["limit"] = limit doc = _Request(self, "geo.getTopArtists", params).execute(cacheable) @@ -506,8 +510,10 @@ class _Network(object): """ params = {"country": country} - if location: params["location"] = location - if limit: params['limit'] = limit + if location: + params["location"] = location + if limit: + params["limit"] = limit doc = _Request(self, "geo.getTopTracks", params).execute(cacheable) @@ -1784,7 +1790,8 @@ class _Opus(_BaseObject, _Taggable): def get_userplaycount(self): """Returns the number of plays by a given username""" - if not self.username: return + if not self.username: + return params = self._get_params() params['username'] = self.username @@ -1938,7 +1945,8 @@ class Artist(_BaseObject, _Taggable): def get_userplaycount(self): """Returns the number of plays by a given username""" - if not self.username: return + if not self.username: + return params = self._get_params() params['username'] = self.username @@ -2380,7 +2388,8 @@ class Metro(_BaseObject): to_date=None, cacheable=True): """Internal helper for getting geo charts.""" params = self._get_params() - if limit: params["limit"] = limit + if limit: + params["limit"] = limit if from_date and to_date: params["from"] = from_date params["to"] = to_date @@ -2936,7 +2945,8 @@ class Track(_Opus): def get_userloved(self): """Whether the user loved this track""" - if not self.username: return + if not self.username: + return params = self._get_params() params['username'] = self.username @@ -3288,7 +3298,8 @@ class User(_BaseObject, _Chartable): if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getNeighbours', cacheable, params) + doc = self._request( + self.ws_prefix + '.getNeighbours', cacheable, params) seq = [] names = _extract_all(doc, 'name') @@ -3481,7 +3492,8 @@ class User(_BaseObject, _Chartable): params = self._get_params() params['period'] = period - if limit: params['limit'] = limit + if limit: + params['limit'] = limit doc = self._request( self.ws_prefix + '.getTopAlbums', cacheable, params) @@ -3500,7 +3512,8 @@ class User(_BaseObject, _Chartable): params = self._get_params() params['period'] = period - if limit: params["limit"] = limit + if limit: + params["limit"] = limit doc = self._request(self.ws_prefix + '.getTopArtists', True, params) @@ -3515,7 +3528,8 @@ class User(_BaseObject, _Chartable): """ params = self._get_params() - if limit: params["limit"] = limit + if limit: + params["limit"] = limit doc = self._request(self.ws_prefix + ".getTopTags", cacheable, params) diff --git a/test_pylast.py b/test_pylast.py index 9f0897d..ca53057 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1700,6 +1700,33 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(tracks[0], pylast.Track) self.assertEqual(len(tracks), 4) + def test_tags(self): + # Arrange + tag1 = self.network.get_tag("blues") + tag2 = self.network.get_tag("rock") + + # Act + tag_repr = repr(tag1) + tag_str = str(tag1) + name = tag1.get_name(properly_capitalized=True) + similar = tag1.get_similar() + url = tag1.get_url() + + # Assert + self.assertEqual("blues", tag_str) + self.assertIn("pylast.Tag", tag_repr) + self.assertIn("blues", tag_repr) + self.assertEqual("blues", name) + self.assertTrue(tag1 == tag1) + self.assertTrue(tag1 != tag2) + self.assertEqual(url, "http://www.last.fm/tag/blues") + found = False + for tag in similar: + if tag.name == "delta blues": + found = True + break + self.assertTrue(found) + if __name__ == '__main__': parser = argparse.ArgumentParser( From d274ef6a3ab8209abffabb83f6b8ed526b6962fd Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 12:55:23 +0300 Subject: [PATCH 141/768] Add some artist tests --- test_pylast.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index ca53057..1d417ba 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1727,6 +1727,29 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(found) + def test_artists(self): + # Arrange + artist1 = self.network.get_artist("Radiohead") + artist2 = self.network.get_artist("Portishead") + + # Act + url = artist1.get_url() + mbid = artist1.get_mbid() + image = artist1.get_cover_image() + playcount = artist1.get_playcount() + streamable = artist1.is_streamable() + name = artist1.get_name(properly_capitalized=False) + name_cap = artist1.get_name(properly_capitalized=True) + + # Assert + self.assertIn("http", image) + self.assertGreater(playcount, 1) + self.assertTrue(artist1 != artist2) + self.assertEqual(name.lower(), name_cap.lower()) + self.assertEqual(url, "http://www.last.fm/music/radiohead") + self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") + self.assertTrue(streamable) # Maybe better just to check if Boolean + if __name__ == '__main__': parser = argparse.ArgumentParser( From 1453e5137c25aad0312c0392192d20268efd6043 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 13:12:40 +0300 Subject: [PATCH 142/768] Add event tests and fix a bug they found --- pylast.py | 2 +- test_pylast.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 66622c6..43841df 100644 --- a/pylast.py +++ b/pylast.py @@ -2107,7 +2107,7 @@ class Event(_BaseObject): @_string_output def __str__(self): - return "Event #" + self.get_id() + return "Event #" + str(self.get_id()) def __eq__(self, other): return self.get_id() == other.get_id() diff --git a/test_pylast.py b/test_pylast.py index 1d417ba..7942b39 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1750,6 +1750,39 @@ class TestPyLast(unittest.TestCase): self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertTrue(streamable) # Maybe better just to check if Boolean + def test_events(self): + # Arrange + event_id_1 = 3162700 # Glasto 2013 + event_id_2 = 3478520 # Glasto 2014 + event1 = pylast.Event(event_id_1, self.network) + event2 = pylast.Event(event_id_2, self.network) + + # Act + text = str(event1) + rep = repr(event1) + title = event1.get_title() + artists = event1.get_artists() + start = event1.get_start_date() + description = event1.get_description() + review_count = event1.get_review_count() + attendance_count = event1.get_attendance_count() + + # Assert + self.assertIn("3162700", rep) + self.assertIn("pylast.Event", rep) + self.assertEqual(text, "Event #3162700") + self.assertTrue(event1 != event2) + self.assertIn("Glastonbury", title) + found = False + for artist in artists: + if artist.name == "The Rolling Stones": + found = True + break + self.assertTrue(found) + self.assertIn("Wed, 26 Jun 2013", start) + self.assertIn("astonishing bundle", description) + self.assertGreater(review_count, 0) + self.assertGreater(attendance_count, 100) if __name__ == '__main__': parser = argparse.ArgumentParser( From db3ed0fc3c599283756658c0eda6985171ebbd93 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Apr 2014 13:18:36 +0300 Subject: [PATCH 143/768] Add country tests --- test_pylast.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test_pylast.py b/test_pylast.py index 7942b39..054d277 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1784,6 +1784,25 @@ class TestPyLast(unittest.TestCase): self.assertGreater(review_count, 0) self.assertGreater(attendance_count, 100) + def test_countries(self): + # Arrange + country1 = pylast.Country("Italy", self.network) + country2 = pylast.Country("Finland", self.network) + + # Act + text = str(country1) + rep = repr(country1) + url = country1.get_url() + + # Assert + self.assertIn("Italy", rep) + self.assertIn("pylast.Country", rep) + self.assertEqual(text, "Italy") + self.assertTrue(country1 == country1) + self.assertTrue(country1 != country2) + self.assertEqual(url, "http://www.last.fm/place/italy") + + if __name__ == '__main__': parser = argparse.ArgumentParser( description="Integration (not unit) tests for pylast.py", From 6a3ba02931bc892a8ac77ec349011239c78b4c4d Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 25 May 2014 13:17:58 +0300 Subject: [PATCH 144/768] Check lowercase album title --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 054d277..600ccc7 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1488,7 +1488,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsInstance(album, pylast.Album) - self.assertEqual(album.title, "TEST") + self.assertEqual(album.title.lower(), "test") self.assertEqual(album_mbid, mbid) def test_artist_mbid(self): From f3d31c96294af408e51564b9c0aa02fadf940376 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 25 May 2014 13:23:10 +0300 Subject: [PATCH 145/768] Check streamable is Boolean rather than a True/False value --- test_pylast.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 600ccc7..04ab3b0 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1748,7 +1748,8 @@ class TestPyLast(unittest.TestCase): self.assertEqual(name.lower(), name_cap.lower()) self.assertEqual(url, "http://www.last.fm/music/radiohead") self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") - self.assertTrue(streamable) # Maybe better just to check if Boolean + self.assertIsInstance(streamable, bool) + def test_events(self): # Arrange From 23e4ae5c1ad3b4ae185e8c53a3eebe01324ab972 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 25 Jul 2014 11:22:14 +0300 Subject: [PATCH 146/768] Change URLs from hugovk to pylast --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dbc04bb..e84b0bc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/hugovk/pylast.png?branch=master)](https://travis-ci.org/hugovk/pylast) [![Coverage Status](https://coveralls.io/repos/hugovk/pylast/badge.png?branch=master)](https://coveralls.io/r/hugovk/pylast?branch=master) +[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). @@ -19,7 +19,7 @@ The old 0.5 version from 2011 is in PyPI so if you have it installed remove it f You can install this version with pip like this: - pip install -e git://github.com/hugovk/pylast.git#egg=pylast + pip install -e git://github.com/pylast/pylast.git#egg=pylast Or just copy [pylast.py](pylast.py) to somewhere your Python can see it. No other dependencies are needed. From b4ea08a378bd12c823b6e68f4b72f3a6b327f8e1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 25 Jul 2014 17:01:14 +0300 Subject: [PATCH 147/768] Change URL, add classifiers and keywords --- setup.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5b62429..ec77269 100755 --- a/setup.py +++ b/setup.py @@ -29,7 +29,20 @@ setup( author="Amr Hassan ", description="A Python interface to Last.fm (and other API compatible social networks)", author_email="amr.hassan@gmail.com", - url="https://github.com/hugovk/pylast", + url="https://github.com/pylast/pylast", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: Apache Software License", + "Topic :: Internet", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + ], + keywords=["Last.fm", "music", "scrobble", "scrobbling"], py_modules=("pylast",), license="Apache2" ) From 1e3551e611d72d95157ba28c1355e303c99a3c0e Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 29 Jul 2014 17:43:44 +0300 Subject: [PATCH 148/768] Bump version to 1.0.0 --- .build | 2 +- pylast.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.build b/.build index 98d9bcb..573541a 100644 --- a/.build +++ b/.build @@ -1 +1 @@ -17 +0 diff --git a/pylast.py b/pylast.py index 43841df..f871b07 100644 --- a/pylast.py +++ b/pylast.py @@ -19,7 +19,7 @@ # # http://code.google.com/p/pylast/ -__version__ = '0.6' +__version__ = '1.0.0' __author__ = 'Amr Hassan' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan" __license__ = "apache2" diff --git a/setup.py b/setup.py index ec77269..94e11f1 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ def get_build(): setup( name="pylast", - version="0.6." + get_build(), + version="1.0." + get_build(), author="Amr Hassan ", description="A Python interface to Last.fm (and other API compatible social networks)", author_email="amr.hassan@gmail.com", From a2e9fad56cd33b9824729d2205547e7f5c8c02fa Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 29 Jul 2014 18:54:03 +0300 Subject: [PATCH 149/768] Update install instructions now 1.0.0 is on PyPI [CI skip] --- README.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e84b0bc..1f5a64e 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,14 @@ A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible we Try using the pydoc utility for help on usage or see [test_pylast.py](test_pylast.py) for examples. -Original code can be found at http://code.google.com/p/pylast/ but hasn't been updated since 2011. - Installation ------------ -The old 0.5 version from 2011 is in PyPI so if you have it installed remove it first: +The easiest way is via pip: - pip uninstall pylast + pip install pylast - -You can install this version with pip like this: - - pip install -e git://github.com/pylast/pylast.git#egg=pylast - -Or just copy [pylast.py](pylast.py) to somewhere your Python can see it. No other dependencies are needed. +Or copy [pylast.py](pylast.py) to somewhere your Python can see it. No other dependencies are needed. Features From 862ae11cfae284ea88db11dc133f64e623da315a Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 18 Aug 2014 16:24:01 +0300 Subject: [PATCH 150/768] Fix opus comparison with None --- pylast.py | 4 ++++ test_pylast.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/pylast.py b/pylast.py index f871b07..49a2f1a 100644 --- a/pylast.py +++ b/pylast.py @@ -1738,6 +1738,8 @@ class _Opus(_BaseObject, _Taggable): self.get_artist().get_name(), self.get_title()) def __eq__(self, other): + if type(self) != type(other): + return False a = self.get_title().lower() b = other.get_title().lower() c = self.get_artist().get_name().lower() @@ -1745,6 +1747,8 @@ class _Opus(_BaseObject, _Taggable): return (a == b) and (c == d) def __ne__(self, other): + if type(self) != type(other): + return True a = self.get_title().lower() b = other.get_title().lower() c = self.get_artist().get_name().lower() diff --git a/test_pylast.py b/test_pylast.py index 04ab3b0..ed2bf9b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1804,6 +1804,23 @@ class TestPyLast(unittest.TestCase): self.assertEqual(url, "http://www.last.fm/place/italy") + def test_track_eq_none_is_false(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "Test Title", self.network) + + # Act / Assert + self.assertFalse(track1 == track2) + + def test_track_ne_none_is_true(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "Test Title", self.network) + + # Act / Assert + self.assertTrue(track1 != track2) + + if __name__ == '__main__': parser = argparse.ArgumentParser( description="Integration (not unit) tests for pylast.py", From 0058c8b832dc97d81a2a054388d1e1c1a47ae42c Mon Sep 17 00:00:00 2001 From: Kilian Koeltzsch Date: Wed, 20 Aug 2014 01:18:41 +0200 Subject: [PATCH 151/768] syntax highlighting in readme --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1f5a64e..34d1028 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Getting Started Here's a simple code example to get you started. In order to create any object from pyLast, you need a Network object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: -``` +```python import pylast # You have to have your own unique two values for API_KEY and API_SECRET @@ -73,7 +73,7 @@ Testing You need a test account at Last.fm that will be cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: -``` +```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE @@ -81,23 +81,23 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` To run all: -``` +```sh pip install -r test_requirements.txt ./test_pylast.py ``` Or run just one: -``` +```sh ./test_pylast.py -1 test_scrobble ``` Or all those tests matching a term: -``` +```sh ./test_pylast.py -m geo ``` To run with coverage: -``` +```sh coverage run --source=pylast ./test_pylast.py coverage report # for command-line report coverage html # for HTML report @@ -105,6 +105,6 @@ open htmlcov/index.html ``` To perform some static analysis: -``` +```sh ./check.sh ``` From 40c964a18c8a02e522875412226cf42d7d55cb29 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 20 Aug 2014 13:01:27 +0300 Subject: [PATCH 152/768] Add badge for number of PyPI downloads [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 34d1028..dbba4ab 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) +[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![Downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From ff69189a6d4489eb7ba594296d9c0b6d0e7040d4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Aug 2014 00:12:27 +0300 Subject: [PATCH 153/768] Add PyPI version badge [CI skip] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dbba4ab..7366cb4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![Downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) +[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) + A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From e117a2b57c25c9b333cc8db4f930a69eea17204f Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Aug 2014 00:16:59 +0300 Subject: [PATCH 154/768] Add artist.get_band_members() with tests --- pylast.py | 23 +++++++++++++++++------ test_pylast.py | 24 +++++++++++++++++++++--- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/pylast.py b/pylast.py index 49a2f1a..7b72178 100644 --- a/pylast.py +++ b/pylast.py @@ -121,10 +121,10 @@ RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + u'|' + u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % - (unichr(0xd800), unichr(0xdbff), unichr(0xdc00), - unichr(0xdfff), unichr(0xd800), unichr(0xdbff), - unichr(0xdc00), unichr(0xdfff), unichr(0xd800), - unichr(0xdbff), unichr(0xdc00), unichr(0xdfff))) + (unichr(0xd800), unichr(0xdbff), unichr(0xdc00), + unichr(0xdfff), unichr(0xd800), unichr(0xdbff), + unichr(0xdc00), unichr(0xdfff), unichr(0xd800), + unichr(0xdbff), unichr(0xdc00), unichr(0xdfff))) XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) @@ -1023,7 +1023,7 @@ class _Request(object): def sign_it(self): """Sign this request.""" - if not "api_sig" in self.params.keys(): + if "api_sig" not in self.params.keys(): self.params['api_sig'] = self._get_signature() def _get_signature(self): @@ -2093,6 +2093,17 @@ class Artist(_BaseObject, _Taggable): self._request("artist.Shout", False, params) + def get_band_members(self): + """Returns a list of band members or None if unknown.""" + + names = None + doc = self._request(self.ws_prefix + ".getInfo", True) + + for node in doc.getElementsByTagName("bandmembers"): + names = _extract_all(node, "name") + + return names + class Event(_BaseObject): """An event.""" @@ -4115,7 +4126,7 @@ def _number(string): def _unescape_htmlentity(string): - #string = _unicode(string) + # string = _unicode(string) mapping = htmlentitydefs.name2codepoint for key in mapping: diff --git a/test_pylast.py b/test_pylast.py index ed2bf9b..0f71c65 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1251,7 +1251,7 @@ class TestPyLast(unittest.TestCase): # Assert # Check inbox for spam! - #album/artist/event/track/user + # album/artist/event/track/user def test_album_shouts(self): # Arrange @@ -1750,7 +1750,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertIsInstance(streamable, bool) - def test_events(self): # Arrange event_id_1 = 3162700 # Glasto 2013 @@ -1803,7 +1802,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(country1 != country2) self.assertEqual(url, "http://www.last.fm/place/italy") - def test_track_eq_none_is_false(self): # Arrange track1 = None @@ -1820,6 +1818,26 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(track1 != track2) + def test_band_members(self): + # Arrange + artist = pylast.Artist("The Beatles", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.assertGreaterEqual(len(band_members), 4) + + def test_no_band_members(self): + # Arrange + artist = pylast.Artist("John Lennon", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.assertIsNone(band_members) + if __name__ == '__main__': parser = argparse.ArgumentParser( From bc7433990b1caf94e9c04beda9773dfec54611eb Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Sep 2014 15:07:02 +0300 Subject: [PATCH 155/768] Fix comparison with None for artist, album and event, with test. For #97. --- pylast.py | 22 +++++++++++----------- test_pylast.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/pylast.py b/pylast.py index 49a2f1a..53ac356 100644 --- a/pylast.py +++ b/pylast.py @@ -1747,13 +1747,7 @@ class _Opus(_BaseObject, _Taggable): return (a == b) and (c == d) def __ne__(self, other): - if type(self) != type(other): - return True - a = self.get_title().lower() - b = other.get_title().lower() - c = self.get_artist().get_name().lower() - d = other.get_artist().get_name().lower() - return (a != b) or (c != d) + return not self.__eq__(other) def _get_params(self): return { @@ -1907,10 +1901,13 @@ class Artist(_BaseObject, _Taggable): return self.get_name() def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() + if type(self) is type(other): + return self.get_name().lower() == other.get_name().lower() + else: + return False def __ne__(self, other): - return self.get_name().lower() != other.get_name().lower() + return not self.__eq__(other) def _get_params(self): return {self.ws_prefix: self.get_name()} @@ -2114,10 +2111,13 @@ class Event(_BaseObject): return "Event #" + str(self.get_id()) def __eq__(self, other): - return self.get_id() == other.get_id() + if type(self) is type(other): + return self.get_id() == other.get_id() + else: + return False def __ne__(self, other): - return self.get_id() != other.get_id() + return not self.__eq__(other) def _get_params(self): return {'event': self.get_id()} diff --git a/test_pylast.py b/test_pylast.py index ed2bf9b..fd4b807 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1803,7 +1803,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(country1 != country2) self.assertEqual(url, "http://www.last.fm/place/italy") - def test_track_eq_none_is_false(self): # Arrange track1 = None @@ -1820,6 +1819,56 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(track1 != track2) + def test_artist_eq_none_is_false(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertFalse(artist1 == artist2) + + def test_artist_ne_none_is_true(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertTrue(artist1 != artist2) + + def test_album_eq_none_is_false(self): + # Arrange + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) + + # Act / Assert + self.assertFalse(album1 == album2) + + def test_album_ne_none_is_true(self): + # Arrange + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) + + # Act / Assert + self.assertTrue(album1 != album2) + + def test_event_eq_none_is_false(self): + # Arrange + event1 = None + event_id = 3478520 # Glasto 2014 + event2 = pylast.Event(event_id, self.network) + + # Act / Assert + self.assertFalse(event1 == event2) + + def test_event_ne_none_is_true(self): + # Arrange + event1 = None + event_id = 3478520 # Glasto 2014 + event2 = pylast.Event(event_id, self.network) + + # Act / Assert + self.assertTrue(event1 != event2) + if __name__ == '__main__': parser = argparse.ArgumentParser( From 2fea3a1b46215012af9682a355246c70fee8749f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Sep 2014 15:17:47 +0300 Subject: [PATCH 156/768] Last.fm returns no results for artist.getsimilar with limit=1, but limit=2 returns 2 --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index fd4b807..0a1b8d9 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -377,7 +377,7 @@ class TestPyLast(unittest.TestCase): def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") - artist = test_artist.get_similar(limit=1)[0].item + artist = test_artist.get_similar(limit=2)[0].item self.assertIsInstance(artist, pylast.Artist) # Act/Assert From 56d1f2791f78045e111b542032df08428dfd1470 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Sep 2014 15:29:25 +0300 Subject: [PATCH 157/768] Fix chart tests that failed due to date ranges --- pylast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 53ac356..baeac57 100644 --- a/pylast.py +++ b/pylast.py @@ -4,6 +4,7 @@ # A Python interface to Last.fm (and other API compatible social networks) # # Copyright 2008-2010 Amr Hassan +# Copyright 2013-2014 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,8 +21,8 @@ # http://code.google.com/p/pylast/ __version__ = '1.0.0' -__author__ = 'Amr Hassan' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan" +__author__ = 'Amr Hassan, hugovk' +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' From def9b2414a2286ad32d62f3c5ca2cf3844abaa18 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Sep 2014 15:37:17 +0300 Subject: [PATCH 158/768] Fix chart tests that failed due to date ranges (take 2) --- .travis.yml | 1 + test_pylast.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c853ea3..8987743 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,3 +28,4 @@ matrix: allow_failures: - python: "3.4" - python: "pypy" + fast_finish: true diff --git a/test_pylast.py b/test_pylast.py index 0a1b8d9..0bd0f0b 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1213,7 +1213,7 @@ class TestPyLast(unittest.TestCase): self.helper_dates_valid(dates) # Act/Assert - self.helper_get_assert_charts(tag, dates[-1]) + self.helper_get_assert_charts(tag, dates[-2]) def test_user_charts(self): # Arrange @@ -1222,7 +1222,7 @@ class TestPyLast(unittest.TestCase): self.helper_dates_valid(dates) # Act/Assert - self.helper_get_assert_charts(lastfm_user, dates[-1]) + self.helper_get_assert_charts(lastfm_user, dates[1]) def test_track_top_fans(self): # Arrange From bd9c351b21d18aedf32a7a4929959b29e326de2d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 25 Oct 2014 15:52:01 +0300 Subject: [PATCH 159/768] Add from/to parameters to get_recent_tracks() --- pylast.py | 54 +++++++++++++++++++++++++++++++++++++------------- test_pylast.py | 20 +++++++++++++++++++ 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/pylast.py b/pylast.py index 7b72178..f2d7268 100644 --- a/pylast.py +++ b/pylast.py @@ -425,12 +425,18 @@ class _Network(object): params = {} - if long: params["long"] = long - if lat: params["lat"] = lat - if location: params["location"] = location - if limit: params["limit"] = limit - if distance: params["distance"] = distance - if tag: params["tag"] = tag + if long: + params["long"] = long + if lat: + params["lat"] = lat + if location: + params["location"] = location + if limit: + params["limit"] = limit + if distance: + params["distance"] = distance + if tag: + params["tag"] = tag if festivalsonly: params["festivalsonly"] = 1 elif not festivalsonly: @@ -671,12 +677,18 @@ class _Network(object): params = {"track": title, "artist": artist} - if album: params["album"] = album - if album_artist: params["albumArtist"] = album_artist - if context: params["context"] = context - if track_number: params["trackNumber"] = track_number - if mbid: params["mbid"] = mbid - if duration: params["duration"] = duration + if album: + params["album"] = album + if album_artist: + params["albumArtist"] = album_artist + if context: + params["context"] = context + if track_number: + params["trackNumber"] = track_number + if mbid: + params["mbid"] = mbid + if duration: + params["duration"] = duration _Request(self, "track.updateNowPlaying", params).execute() @@ -3377,12 +3389,22 @@ class User(_BaseObject, _Chartable): return Track(artist, title, self.network, self.name) - def get_recent_tracks(self, limit=10, cacheable=True): + def get_recent_tracks(self, limit=10, cacheable=True, + time_from=None, time_to=None): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. - If limit==None, it will try to pull all the available data. + Parameters: + limit : If None, it will try to pull all the available data. + from (Optional) : Beginning timestamp of a range - only display + scrobbles after this time, in UNIX timestamp format (integer + number of seconds since 00:00:00, January 1st 1970 UTC). This + must be in the UTC time zone. + to (Optional) : End timestamp of a range - only display scrobbles + before this time, in UNIX timestamp format (integer number of + seconds since 00:00:00, January 1st 1970 UTC). This must be in + the UTC time zone. This method uses caching. Enable caching only if you're pulling a large amount of data. @@ -3394,6 +3416,10 @@ class User(_BaseObject, _Chartable): params = self._get_params() if limit: params['limit'] = limit + if time_from: + params['from'] = time_from + if time_to: + params['to'] = time_to seq = [] for track in _collect_nodes( diff --git a/test_pylast.py b/test_pylast.py index 0f71c65..2216e00 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -1838,6 +1838,26 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(band_members) + def test_get_recent_tracks_from_to(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + + from datetime import datetime + start = datetime(2011, 7, 21, 15, 10) + end = datetime(2011, 7, 21, 15, 15) + import calendar + utc_start = calendar.timegm(start.utctimetuple()) + utc_end = calendar.timegm(end.utctimetuple()) + + # Act + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, + time_to=utc_end) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") + self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + if __name__ == '__main__': parser = argparse.ArgumentParser( From 8a53afd41b93b30e8855d09d3a5cbb8d5537e5c9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 18 Dec 2014 21:09:22 +0200 Subject: [PATCH 160/768] Badges! [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7366cb4..d089d68 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) +[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/hugovk/pylast/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/hugovk/pylast/?branch=master) [![Code Health](https://landscape.io/github/hugovk/pylast/master/landscape.svg)](https://landscape.io/github/hugovk/pylast/master) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From 3941fd8ab59f37986808d63b2a543c29b7a251f5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 18 Dec 2014 21:13:28 +0200 Subject: [PATCH 161/768] Make test more robust --- setup.py | 3 ++- test_pylast.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 94e11f1..c166df7 100755 --- a/setup.py +++ b/setup.py @@ -27,7 +27,8 @@ setup( name="pylast", version="1.0." + get_build(), author="Amr Hassan ", - description="A Python interface to Last.fm (and other API compatible social networks)", + description=("A Python interface to Last.fm " + "(and other API compatible social networks)"), author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", classifiers=[ diff --git a/test_pylast.py b/test_pylast.py index 52244bf..81dcbbc 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -863,7 +863,8 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(events), 1) event = events[0] self.assertIsInstance(event, pylast.Event) - self.assertEqual(event.get_venue().location['city'], "London") + self.assertIn(event.get_venue().location['city'], + ["London", "Camden"]) def test_geo_get_events_in_latlong(self): # Arrange From 813a72b61a42232d8cf8f1fcab5cdc65aaba3fe0 Mon Sep 17 00:00:00 2001 From: Peter Jeschke Date: Tue, 23 Dec 2014 11:02:42 +0100 Subject: [PATCH 162/768] Update pylast.py _get_proxy is undefined in _Request, the network was missing here. --- pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index f2d7268..e989134 100644 --- a/pylast.py +++ b/pylast.py @@ -1109,7 +1109,7 @@ class _Request(object): if self.network.is_proxy_enabled(): conn = HTTPConnection( - host=self._get_proxy()[0], port=self._get_proxy()[1]) + host=self.network._get_proxy()[0], port=self.network._get_proxy()[1]) try: conn.request( From 59769d31d64d91b01037e4fb5ce8a947031e8775 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 15:28:25 +0200 Subject: [PATCH 163/768] Use Docker --- .travis.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.travis.yml b/.travis.yml index 8987743..ceca581 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,22 @@ language: python + python: - "2.7" - "3.3" - "3.4" - "pypy" + +sudo: false + install: - pip install -r test_requirements.txt - pip install coveralls + script: coverage run --source=pylast ./test_pylast.py + after_success: coveralls + after_script: - coverage report - ./check.sh @@ -17,6 +24,7 @@ after_script: - clonedigger pylast.py - grep "Clones detected" output.html - grep "lines are duplicates" output.html + env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= From 0ae79be3a0de000e386f149d6428814998d2d8a2 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 17:16:41 +0200 Subject: [PATCH 164/768] Add pypy3; track coverage in Scrutinizer --- .scrutinizer.yml | 9 +++++++++ .travis.yml | 10 +++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 .scrutinizer.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..43dbfa3 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,9 @@ +checks: + python: + code_rating: true + duplicate_code: true +filter: + excluded_paths: + - '*/test/*' +tools: + external_code_coverage: true diff --git a/.travis.yml b/.travis.yml index ceca581..9271b54 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,17 +5,20 @@ python: - "3.3" - "3.4" - "pypy" +- "pypy3" sudo: false install: -- pip install -r test_requirements.txt -- pip install coveralls +- travis_retry pip install -r test_requirements.txt +- travis_retry pip install coveralls script: coverage run --source=pylast ./test_pylast.py after_success: - coveralls +- coveralls +- travis_retry pip install scrutinizer-ocular +- ocular after_script: - coverage report @@ -36,4 +39,5 @@ matrix: allow_failures: - python: "3.4" - python: "pypy" + - python: "pypy3" fast_finish: true From 0a158e72383f86132ac67e5e45f1d95589a75f18 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 17:31:10 +0200 Subject: [PATCH 165/768] Landscape.io fixes --- pylast.py | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/pylast.py b/pylast.py index dda9720..132ed72 100644 --- a/pylast.py +++ b/pylast.py @@ -312,9 +312,9 @@ class _Network(object): if domain_language in self.domain_names: return self.domain_names[domain_language] - def _get_url(self, domain, type): + def _get_url(self, domain, url_type): return "http://%s/%s" % ( - self._get_language_domain(domain), self.urls[type]) + self._get_language_domain(domain), self.urls[url_type]) def _get_ws_auth(self): """ @@ -404,14 +404,14 @@ class _Network(object): return seq def get_geo_events( - self, long=None, lat=None, location=None, distance=None, + self, longitude=None, latitude=None, location=None, distance=None, tag=None, festivalsonly=None, limit=None, cacheable=True): """ Returns all events in a specific location by country or city name. Parameters: - long (Optional) : Specifies a longitude value to retrieve events for - (service returns nearby events by default) - lat (Optional) : Specifies a latitude value to retrieve events for + longitude (Optional) : Specifies a longitude value to retrieve events + for (service returns nearby events by default) + latitude (Optional) : Specifies a latitude value to retrieve events for (service returns nearby events by default) location (Optional) : Specifies a location to retrieve events for (service returns nearby events by default) @@ -426,10 +426,10 @@ class _Network(object): params = {} - if long: - params["long"] = long - if lat: - params["lat"] = lat + if longitude: + params["long"] = longlongitude + if latitude: + params["lat"] = latitude if location: params["location"] = location if limit: @@ -768,17 +768,17 @@ class _Network(object): if remaining_tracks: self.scrobble_many(remaining_tracks) - def get_play_links(self, type, things, cacheable=True): + def get_play_links(self, link_type, things, cacheable=True): method = type + ".getPlaylinks" params = {} for i, thing in enumerate(things): - if type == "artist": + if link_type == "artist": params['artist[' + str(i) + ']'] = thing - elif type == "album": + elif link_type == "album": params['artist[' + str(i) + ']'] = thing.artist params['album[' + str(i) + ']'] = thing.title - elif type == "track": + elif link_type == "track": params['artist[' + str(i) + ']'] = thing.artist params['track[' + str(i) + ']'] = thing.title @@ -793,13 +793,13 @@ class _Network(object): return seq def get_artist_play_links(self, artists, cacheable=True): - return self.get_play_links("artist", artists) + return self.get_play_links("artist", artists, cacheable) def get_album_play_links(self, albums, cacheable=True): - return self.get_play_links("album", albums) + return self.get_play_links("album", albums, cacheable) def get_track_play_links(self, tracks, cacheable=True): - return self.get_play_links("track", tracks) + return self.get_play_links("track", tracks, cacheable) class LastFMNetwork(_Network): @@ -2089,6 +2089,8 @@ class Artist(_BaseObject, _Taggable): """ The artist.getImages method has been deprecated by Last.fm. """ + assert order # silence warnings + assert limit # silence warnings raise WSError( self.network, "27", "The artist.getImages method has been deprecated by Last.fm.") @@ -2717,7 +2719,7 @@ class Playlist(_BaseObject): __hash__ = _BaseObject.__hash__ - def __init__(self, user, id, network): + def __init__(self, user, playlist_id, network): _BaseObject.__init__(self, network, "playlist") if isinstance(user, User): @@ -2725,7 +2727,7 @@ class Playlist(_BaseObject): else: self.user = User(user, self.network) - self.id = id + self.id = playlist_id @_string_output def __str__(self): @@ -3897,10 +3899,10 @@ class Venue(_BaseObject): __hash__ = _BaseObject.__hash__ - def __init__(self, id, network, venue_element=None): + def __init__(self, netword_id, network, venue_element=None): _BaseObject.__init__(self, network, "venue") - self.id = _number(id) + self.id = _number(netword_id) if venue_element is not None: self.info = _extract_element_tree(venue_element) self.name = self.info.get('name') @@ -4213,7 +4215,7 @@ class _ScrobblerRequest(object): def __init__(self, url, params, network, type="POST"): for key in params: - params[key] = str(params[key]) + params[key] = str(params[key]) self.params = params self.type = type From 2e560849741f3390a4d6c530980178dfba5a7bec Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 18:57:22 +0200 Subject: [PATCH 166/768] Remove asserts which can fail --- pylast.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pylast.py b/pylast.py index 132ed72..2207b98 100644 --- a/pylast.py +++ b/pylast.py @@ -2089,8 +2089,6 @@ class Artist(_BaseObject, _Taggable): """ The artist.getImages method has been deprecated by Last.fm. """ - assert order # silence warnings - assert limit # silence warnings raise WSError( self.network, "27", "The artist.getImages method has been deprecated by Last.fm.") From 04d26a2c79f663be895b3ee4c07ff5f6cda07b45 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 23:12:18 +0200 Subject: [PATCH 167/768] Fix params --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index 81dcbbc..bfdcbbe 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -870,7 +870,7 @@ class TestPyLast(unittest.TestCase): # Arrange # Act events = self.network.get_geo_events( - lat=53.466667, long=-2.233333, distance=5, limit=1) + latitude=53.466667, longitude=-2.233333, distance=5, limit=1) # Assert self.assertEqual(len(events), 1) From 5a15b3b6a61e91b6ec1e96870f4b692d4d536dd5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 23:13:40 +0200 Subject: [PATCH 168/768] Fix params --- pylast.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pylast.py b/pylast.py index 2207b98..7dd418b 100644 --- a/pylast.py +++ b/pylast.py @@ -427,7 +427,7 @@ class _Network(object): params = {} if longitude: - params["long"] = longlongitude + params["long"] = longitude if latitude: params["lat"] = latitude if location: @@ -1110,7 +1110,8 @@ class _Request(object): if self.network.is_proxy_enabled(): conn = HTTPConnection( - host=self.network._get_proxy()[0], port=self.network._get_proxy()[1]) + host=self.network._get_proxy()[0], + port=self.network._get_proxy()[1]) try: conn.request( From 24f49074bfd710e96d5bed3880b8a309ebd6c966 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 23:20:39 +0200 Subject: [PATCH 169/768] Fix params --- pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index 7dd418b..45ce68d 100644 --- a/pylast.py +++ b/pylast.py @@ -769,7 +769,7 @@ class _Network(object): self.scrobble_many(remaining_tracks) def get_play_links(self, link_type, things, cacheable=True): - method = type + ".getPlaylinks" + method = link_type + ".getPlaylinks" params = {} for i, thing in enumerate(things): From 79d741269ac9fa23d224b13aca0ab2c86be52267 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 26 Dec 2014 23:32:21 +0200 Subject: [PATCH 170/768] Fix params --- test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_pylast.py b/test_pylast.py index bfdcbbe..bc5dcb8 100755 --- a/test_pylast.py +++ b/test_pylast.py @@ -422,7 +422,7 @@ class TestPyLast(unittest.TestCase): def test_playlist_is_hashable(self): # Arrange playlist = pylast.Playlist( - user="RJ", id="1k1qp_doglist", network=self.network) + user="RJ", playlist_id="1k1qp_doglist", network=self.network) # Act/Assert self.helper_is_thing_hashable(playlist) From a0127432f827011ff592a2ea627d376a039770cd Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 5 Jan 2015 10:16:07 -0800 Subject: [PATCH 171/768] Fix word in SessionKeyGenerator comment. --- pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast.py b/pylast.py index dda9720..43e9eb7 100644 --- a/pylast.py +++ b/pylast.py @@ -1180,7 +1180,7 @@ class SessionKeyGenerator(object): d. session_key = SessionKeyGenerator(network).get_session_key(username, password_hash) - A session key's lifetime is infinite, unless the user provokes the rights + A session key's lifetime is infinite, unless the user revokes the rights of the given API Key. If you create a Network object with just a API_KEY and API_SECRET and a From aa022c5a71fab99d9f360c4c275d995fbb8eed0b Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 5 Jan 2015 23:19:23 +0200 Subject: [PATCH 172/768] Don't use .build file for minor version because we define the full version in pylast.py as well --- .build | 1 - MANIFEST.in | 1 - setup.py | 20 +------------------- 3 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 .build diff --git a/.build b/.build deleted file mode 100644 index 573541a..0000000 --- a/.build +++ /dev/null @@ -1 +0,0 @@ -0 diff --git a/MANIFEST.in b/MANIFEST.in index 9e84b8c..022f2b4 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,3 @@ include setup.py include README include COPYING include INSTALL -include .build diff --git a/setup.py b/setup.py index c166df7..9579b9a 100755 --- a/setup.py +++ b/setup.py @@ -5,27 +5,9 @@ from distutils.core import setup import os -def get_build(): - path = "./.build" - - if os.path.exists(path): - fp = open(path, "r") - build = eval(fp.read()) - if os.path.exists("./.increase_build"): - build += 1 - fp.close() - else: - build = 1 - - fp = open(path, "w") - fp.write(str(build)) - fp.close() - - return str(build) - setup( name="pylast", - version="1.0." + get_build(), + version="1.0.0", author="Amr Hassan ", description=("A Python interface to Last.fm " "(and other API compatible social networks)"), From 9b9dc2e2c54f4dd68873c9085631cc3d4fa15776 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 5 Jan 2015 23:20:22 +0200 Subject: [PATCH 173/768] Remove unused import --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 9579b9a..baadad3 100755 --- a/setup.py +++ b/setup.py @@ -2,8 +2,6 @@ from distutils.core import setup -import os - setup( name="pylast", From 387220c1d67a88dde6d66e3581e6bd428cd10ed6 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 5 Jan 2015 15:52:02 -0800 Subject: [PATCH 174/768] Make pylast a package. Move tests into a directory. Start using pytest and tox. Use setuptools instead of distutils. --- .build | 1 + .travis.yml | 4 ++-- check.sh | 8 ++++---- pylast.py => pylast/__init__.py | 0 setup.py | 8 +++++--- tests/__init__.py | 0 test_pylast.py => tests/test_pylast.py | 0 tox.ini | 11 +++++++++++ 8 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 .build rename pylast.py => pylast/__init__.py (100%) create mode 100644 tests/__init__.py rename test_pylast.py => tests/test_pylast.py (100%) create mode 100644 tox.ini diff --git a/.build b/.build new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/.build @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 9271b54..9efd262 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,7 +13,7 @@ install: - travis_retry pip install -r test_requirements.txt - travis_retry pip install coveralls -script: coverage run --source=pylast ./test_pylast.py +script: coverage run --source=pylast ./tests/test_pylast.py after_success: - coveralls @@ -24,7 +24,7 @@ after_script: - coverage report - ./check.sh - pip install clonedigger -- clonedigger pylast.py +- clonedigger pylast - grep "Clones detected" output.html - grep "lines are duplicates" output.html diff --git a/check.sh b/check.sh index 0e2c1d2..2aca7ef 100755 --- a/check.sh +++ b/check.sh @@ -1,10 +1,10 @@ -pyflakes pylast.py +pyflakes pylast echo --- -pyflakes test_pylast.py +pyflakes tests echo --- -pep8 test_pylast.py +pep8 pylast echo --- -pep8 pylast.py +pep8 tests # echo --- # clonedigger pylast.py # grep "Clones detected" output.html diff --git a/pylast.py b/pylast/__init__.py similarity index 100% rename from pylast.py rename to pylast/__init__.py diff --git a/setup.py b/setup.py index baadad3..8e73099 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,14 @@ #!/usr/bin/env python +import os -from distutils.core import setup +from setuptools import setup, find_packages setup( name="pylast", version="1.0.0", author="Amr Hassan ", + tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], description=("A Python interface to Last.fm " "(and other API compatible social networks)"), author_email="amr.hassan@gmail.com", @@ -24,8 +26,8 @@ setup( "Programming Language :: Python :: 3.4", ], keywords=["Last.fm", "music", "scrobble", "scrobbling"], - py_modules=("pylast",), + packages=find_packages(exclude=('tests*')), license="Apache2" - ) +) # End of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_pylast.py b/tests/test_pylast.py similarity index 100% rename from test_pylast.py rename to tests/test_pylast.py diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8565df0 --- /dev/null +++ b/tox.ini @@ -0,0 +1,11 @@ +[tox] +envlist = py34, py27 +recreate = False + +[testenv] +downloadcache = {homedir}/.pipcache +changedir = tests +deps = + pytest + mock +commands = py.test {posargs} \ No newline at end of file From c2b03afe559c4af1c77d40c02bfc7d7d01e039c9 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 5 Jan 2015 15:55:45 -0800 Subject: [PATCH 175/768] Don't encode strings as 'utf-8' twice in _get_cache_key. Closes #109. --- pylast/__init__.py | 2 +- tests/request_test.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/request_test.py diff --git a/pylast/__init__.py b/pylast/__init__.py index 8005c71..7020eee 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1070,7 +1070,7 @@ class _Request(object): for key in keys: if key != "api_sig" and key != "api_key" and key != "sk": - cache_key += key + _string(self.params[key]) + cache_key += key + self.params[key] return hashlib.sha1(cache_key.encode("utf-8")).hexdigest() diff --git a/tests/request_test.py b/tests/request_test.py new file mode 100644 index 0000000..fb18091 --- /dev/null +++ b/tests/request_test.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +import mock +import pytest + +import pylast + + +def mock_network(): + return mock.Mock( + _get_ws_auth=mock.Mock(return_value=("", "", "")) + ) + +@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'éééééééé']) +def test_get_cache_key(unicode_artist): + request = pylast._Request(mock_network(), 'some_method', + params={'artist': unicode_artist}) + request._get_cache_key() From ca66e74099104183956b61944fc76a2746ebf40f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 17:57:04 -0800 Subject: [PATCH 176/768] tox/travis stuff. making testing more uniform. --- .travis.yml | 30 ++++++++++--------------- check.sh | 11 ---------- test_requirements.txt | 4 ---- tests/request_test.py | 3 ++- tests/test_pylast.py | 51 ------------------------------------------- tox.ini | 25 ++++++++++++++++++--- 6 files changed, 35 insertions(+), 89 deletions(-) delete mode 100755 check.sh delete mode 100644 test_requirements.txt diff --git a/.travis.yml b/.travis.yml index 9efd262..f96caf8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,32 +1,24 @@ language: python -python: -- "2.7" -- "3.3" -- "3.4" -- "pypy" -- "pypy3" +env: +- TOXENV=py27 +- TOXENV=py33 +- TOXENV=py34 +- TOXENV=pypy +- TOXENV=pypy3 +- TOXENV=lint sudo: false install: -- travis_retry pip install -r test_requirements.txt - travis_retry pip install coveralls -script: coverage run --source=pylast ./tests/test_pylast.py +script: tox after_success: -- coveralls -- travis_retry pip install scrutinizer-ocular -- ocular - -after_script: -- coverage report -- ./check.sh -- pip install clonedigger -- clonedigger pylast -- grep "Clones detected" output.html -- grep "lines are duplicates" output.html +- "if [ $TOXENV == 'py27' ]; then pip install coveralls; coveralls; fi" +- "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" +- "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" env: global: diff --git a/check.sh b/check.sh deleted file mode 100755 index 2aca7ef..0000000 --- a/check.sh +++ /dev/null @@ -1,11 +0,0 @@ -pyflakes pylast -echo --- -pyflakes tests -echo --- -pep8 pylast -echo --- -pep8 tests -# echo --- -# clonedigger pylast.py -# grep "Clones detected" output.html -# grep "lines are duplicates" output.html diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index d304a29..0000000 --- a/test_requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -coverage -pep8 -pyyaml -pyflakes diff --git a/tests/request_test.py b/tests/request_test.py index fb18091..bb72f62 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -10,7 +10,8 @@ def mock_network(): _get_ws_auth=mock.Mock(return_value=("", "", "")) ) -@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'éééééééé']) + +@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé]) def test_get_cache_key(unicode_artist): request = pylast._Request(mock_network(), 'some_method', params={'artist': unicode_artist}) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index bc5dcb8..8f2fe90 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2,7 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import argparse import os from random import choice import sys @@ -1909,54 +1908,4 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - -if __name__ == '__main__': - parser = argparse.ArgumentParser( - description="Integration (not unit) tests for pylast.py", - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument( - '-1', '--single', - help="Run a single test") - parser.add_argument( - '-r', '--repeat', - help="Repeat a single test (100 times) until failure") - parser.add_argument( - '-m', '--matching', - help="Run tests with this in the name") - args = parser.parse_args() - - if args.single: - suite = unittest.TestSuite() - - suite.addTest(TestPyLast(args.single)) - unittest.TextTestRunner().run(suite) - - elif args.repeat: - suite = unittest.TestSuite() - - suite.addTest(TestPyLast(args.repeat)) - for i in range(100): - print("Attempt " + str(i+1)) - result = unittest.TextTestRunner().run(suite) - problems = len(result.errors) + len(result.failures) - if problems: - break - - elif args.matching: - suite = unittest.TestSuite() - - import inspect - methods = inspect.getmembers(TestPyLast, predicate=inspect.ismethod) - - tests = [] - for method, _ in methods: - if method.startswith("test_") and args.matching in method: - print(method) - suite.addTest(TestPyLast(method)) - - unittest.TextTestRunner().run(suite) - - else: - unittest.main(failfast=True) - # End of file diff --git a/tox.ini b/tox.ini index 8565df0..03fee87 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,30 @@ [tox] -envlist = py34, py27 +envlist = py34, py27, pypy, pypy3 recreate = False [testenv] downloadcache = {homedir}/.pipcache -changedir = tests deps = + pyyaml pytest mock -commands = py.test {posargs} \ No newline at end of file + pytest-cov +commands = py.test --doctest-modules -v --cov pylast --cov-report term-missing + +[testenv:venv] +deps = ipdb +commands = {posargs} + +[testenv:lint] +deps = + coverage + pep8 + pyyaml + pyflakes + clonedigger +commands = + pyflakes pylast + pyflakes tests + pep8 pylast + pep8 tests + clonedigger pylast -o /dev/stdout | grep -E "Clones detected\|lines are duplicates" \ No newline at end of file From a64d562ae2bb5f0e7781866b3ab407b821d8805f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 18:00:36 -0800 Subject: [PATCH 177/768] Add tox to travis installation. --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index f96caf8..63c9582 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ env: sudo: false install: +- travis_retry pip install tox - travis_retry pip install coveralls script: tox From b66ee21ce01be0d4d1db07eead1b82ca3f123f93 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 18:04:46 -0800 Subject: [PATCH 178/768] Consolidate travis environments. separate matrix and global environment variables. --- .travis.yml | 25 ++++++++++++------------- tests/request_test.py | 2 +- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 63c9582..fc5e997 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,18 @@ language: python env: -- TOXENV=py27 -- TOXENV=py33 -- TOXENV=py34 -- TOXENV=pypy -- TOXENV=pypy3 -- TOXENV=lint + global: + - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= + - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= + - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= + - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= + matrix: + - TOXENV=py27 + - TOXENV=py33 + - TOXENV=py34 + - TOXENV=pypy + - TOXENV=pypy3 + - TOXENV=lint sudo: false @@ -21,13 +27,6 @@ after_success: - "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" - "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" -env: - global: - - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= - - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= - - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= - - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= - matrix: allow_failures: - python: "3.4" diff --git a/tests/request_test.py b/tests/request_test.py index bb72f62..24fdf66 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -11,7 +11,7 @@ def mock_network(): ) -@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé]) +@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé']) def test_get_cache_key(unicode_artist): request = pylast._Request(mock_network(), 'some_method', params={'artist': unicode_artist}) From 6e6ff1a41703f0db49759353684f8fe428babcd9 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 18:20:30 -0800 Subject: [PATCH 179/768] Get rid of --doctest-modules in py.test command. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 03fee87..5d6cdb9 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps = pytest mock pytest-cov -commands = py.test --doctest-modules -v --cov pylast --cov-report term-missing +commands = py.test -v --cov pylast --cov-report term-missing [testenv:venv] deps = ipdb From f2f8ca086d21aefdabe7571cd8c9b6a4c7c2aa94 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 19:25:41 -0800 Subject: [PATCH 180/768] add posargs to tox tests. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 5d6cdb9..21c569b 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps = pytest mock pytest-cov -commands = py.test -v --cov pylast --cov-report term-missing +commands = py.test -v --cov pylast --cov-report term-missing {posargs} [testenv:venv] deps = ipdb From 6d84446f0385cce2b0195f57920d07118bf8b724 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 19:27:28 -0800 Subject: [PATCH 181/768] Add failing test for #114 --- tests/request_test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/request_test.py b/tests/request_test.py index 24fdf66..1c8a4c2 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -11,8 +11,11 @@ def mock_network(): ) -@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé']) -def test_get_cache_key(unicode_artist): +@pytest.mark.parametrize('troublesome_artist', [ + u'\xe9lafdasfdsafdsa', u'ééééééé', + pylast.Artist(u'B\xe9l', mock_network()) +]) +def test_get_cache_key(troublesome_artist): request = pylast._Request(mock_network(), 'some_method', - params={'artist': unicode_artist}) + params={'artist': troublesome_artist}) request._get_cache_key() From f1e14f591c083b8bb1834aa9f8c5ce66e4d5a458 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 19:39:09 -0800 Subject: [PATCH 182/768] add credentials for IvanMalison/pylast. --- .travis.yml | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.travis.yml b/.travis.yml index fc5e997..e5dd406 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,35 +1,35 @@ language: python - env: global: - - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= - - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= - - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= - - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= + - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= + - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= + - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= + - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= + - secure: DxBvGGoIgbAeuuU3A6+J1HBbmUAEvqdmK73etw+yNKDLGvvukgTL33dNCr8CZXLKRRvfhrjU7Q01GUpOTxrVQ9nJgsD55kwx0wPtuBWIF80M2m4SPsiVLlwP/LFYD5JMDTDWjFTlVahma8P7qoLjCc7b/RgigWLidH19snQmjdY= + - secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs= + - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y= + - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= matrix: - - TOXENV=py27 - - TOXENV=py33 - - TOXENV=py34 - - TOXENV=pypy - - TOXENV=pypy3 - - TOXENV=lint - + - TOXENV=py27 + - TOXENV=py33 + - TOXENV=py34 + - TOXENV=pypy + - TOXENV=pypy3 + - TOXENV=lint sudo: false - install: - travis_retry pip install tox - travis_retry pip install coveralls - script: tox - after_success: -- "if [ $TOXENV == 'py27' ]; then pip install coveralls; coveralls; fi" -- "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" -- "if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; fi" - +- if [ $TOXENV == 'py27' ]; then pip install coveralls; coveralls; fi +- if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; + fi +- if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; + fi matrix: allow_failures: - - python: "3.4" - - python: "pypy" - - python: "pypy3" + - python: '3.4' + - python: pypy + - python: pypy3 fast_finish: true From 781df0c94f79e4fc8f8c62c00fdc63c1c91ee03a Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 23:31:07 -0800 Subject: [PATCH 183/768] Remove duplicate line, Make test_pylast.py runnable as main again. --- .travis.yml | 7 ++----- tests/test_pylast.py | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index e5dd406..5c0f5be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,11 +22,8 @@ install: - travis_retry pip install coveralls script: tox after_success: -- if [ $TOXENV == 'py27' ]; then pip install coveralls; coveralls; fi -- if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; - fi -- if [ $TOXENV == 'py27' ]; then travis_retry pip install scrutinizer-ocular; ocular; - fi +- travis_retry pip install coveralls && coveralls +- travis_retry pip install scrutinizer-ocular && ocular matrix: allow_failures: - python: '3.4' diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 8f2fe90..36f5c60 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1908,4 +1908,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") -# End of file + +if __name__ == '__main__': + unittest.main(failfast=True) From dd7fe504d37bd9a40c35d99881489b11e70586c0 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 7 Jan 2015 23:31:48 -0800 Subject: [PATCH 184/768] Revert "Add failing test for #114" (This revert should be reverted later as the test from this commit should be made to pass eventually) This reverts commit 6d84446f0385cce2b0195f57920d07118bf8b724. --- tests/request_test.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/request_test.py b/tests/request_test.py index 1c8a4c2..24fdf66 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -11,11 +11,8 @@ def mock_network(): ) -@pytest.mark.parametrize('troublesome_artist', [ - u'\xe9lafdasfdsafdsa', u'ééééééé', - pylast.Artist(u'B\xe9l', mock_network()) -]) -def test_get_cache_key(troublesome_artist): +@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé']) +def test_get_cache_key(unicode_artist): request = pylast._Request(mock_network(), 'some_method', - params={'artist': troublesome_artist}) + params={'artist': unicode_artist}) request._get_cache_key() From da26531c87978f56ae2b3ef7e4be543f56eab334 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 8 Jan 2015 11:14:40 +0200 Subject: [PATCH 185/768] Skip if PYLAST_USERNAME etc. env vars missing --- tests/test_pylast.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 36f5c60..37695bf 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -3,6 +3,7 @@ Integration (not unit) tests for pylast.py """ import os +import pytest from random import choice import sys import time @@ -19,10 +20,13 @@ def load_secrets(): doc = yaml.load(f) else: doc = {} - doc["username"] = os.environ['PYLAST_USERNAME'].strip() - doc["password_hash"] = os.environ['PYLAST_PASSWORD_HASH'].strip() - doc["api_key"] = os.environ['PYLAST_API_KEY'].strip() - doc["api_secret"] = os.environ['PYLAST_API_SECRET'].strip() + try: + doc["username"] = os.environ['PYLAST_USERNAME'].strip() + doc["password_hash"] = os.environ['PYLAST_PASSWORD_HASH'].strip() + doc["api_key"] = os.environ['PYLAST_API_KEY'].strip() + doc["api_secret"] = os.environ['PYLAST_API_SECRET'].strip() + except KeyError: + pytest.skip("Missing environment variables: PYLAST_USERNAME etc.") return doc From df75cf2aa2434224dbd99dbf5177d9bfc30558f8 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 8 Jan 2015 01:03:52 -0800 Subject: [PATCH 186/768] Fix UnicodeDecodeError from #114. Replace definitions of _unicode and _string. Add six as a dependeny. Fix clonedigger script. --- .dir-locals.el | 1 + clonedigger.sh | 3 +++ pylast/__init__.py | 51 ++++++++++++++++--------------------------- setup.py | 1 + tests/request_test.py | 18 --------------- tests/unicode_test.py | 28 ++++++++++++++++++++++++ tox.ini | 3 ++- 7 files changed, 54 insertions(+), 51 deletions(-) create mode 100644 .dir-locals.el create mode 100755 clonedigger.sh delete mode 100644 tests/request_test.py create mode 100644 tests/unicode_test.py diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..427ee57 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1 @@ +((nil . ((pytest-global-name . "source secrets.sh && tox -e py34 --")))) diff --git a/clonedigger.sh b/clonedigger.sh new file mode 100755 index 0000000..14a016f --- /dev/null +++ b/clonedigger.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +clonedigger pylast +! grep -E "Clones detected\|lines are duplicates" output.html diff --git a/pylast/__init__.py b/pylast/__init__.py index 7020eee..88b7ccb 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -37,6 +37,8 @@ import collections import warnings import re +import six + def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -1910,9 +1912,12 @@ class Artist(_BaseObject, _Taggable): return "pylast.Artist(%s, %s)" % ( repr(self.get_name()), repr(self.network)) + def __unicode__(self): + return six.text_type(self.get_name()) + @_string_output def __str__(self): - return self.get_name() + return self.__unicode__() def __eq__(self, other): if type(self) is type(other): @@ -3966,40 +3971,22 @@ def md5(text): def _unicode(text): - if sys.version_info[0] == 3: - if type(text) in (bytes, bytearray): - return str(text, "utf-8") - elif type(text) == str: - return text - else: - return str(text) - - elif sys.version_info[0] == 2: - if type(text) in (str,): - return unicode(text, "utf-8") - elif type(text) == unicode: - return text - else: - return unicode(text) + if isinstance(text, six.binary_type): + return six.text_type(text, "utf-8") + elif isinstance(text, six.text_type): + return text + else: + return six.text_type(text) -def _string(text): +def _string(string): """For Python2 routines that can only process str type.""" - - if sys.version_info[0] == 3: - if type(text) != str: - return str(text) - else: - return text - - elif sys.version_info[0] == 2: - if type(text) == str: - return text - - if type(text) == int: - return str(text) - - return text.encode("utf-8") + if isinstance(string, str): + return string + casted = six.text_type(string) + if sys.version_info[0] == 2: + casted = casted.encode("utf-8") + return casted def _collect_nodes(limit, sender, method_name, cacheable, params=None): diff --git a/setup.py b/setup.py index 8e73099..2d58901 100755 --- a/setup.py +++ b/setup.py @@ -8,6 +8,7 @@ setup( name="pylast", version="1.0.0", author="Amr Hassan ", + install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], description=("A Python interface to Last.fm " "(and other API compatible social networks)"), diff --git a/tests/request_test.py b/tests/request_test.py deleted file mode 100644 index 24fdf66..0000000 --- a/tests/request_test.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -import mock -import pytest - -import pylast - - -def mock_network(): - return mock.Mock( - _get_ws_auth=mock.Mock(return_value=("", "", "")) - ) - - -@pytest.mark.parametrize('unicode_artist', [u'\xe9lafdasfdsafdsa', u'ééééééé']) -def test_get_cache_key(unicode_artist): - request = pylast._Request(mock_network(), 'some_method', - params={'artist': unicode_artist}) - request._get_cache_key() diff --git a/tests/unicode_test.py b/tests/unicode_test.py new file mode 100644 index 0000000..d05c529 --- /dev/null +++ b/tests/unicode_test.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +import mock +import pytest +import six + +import pylast + + +def mock_network(): + return mock.Mock( + _get_ws_auth=mock.Mock(return_value=("", "", "")) + ) + + +@pytest.mark.parametrize('artist', [ + u'\xe9lafdasfdsafdsa', u'ééééééé', + pylast.Artist(u'B\xe9l', mock_network()), + 'fdasfdsafsaf not unicode', +]) +def test_get_cache_key(artist): + request = pylast._Request(mock_network(), 'some_method', + params={'artist': artist}) + request._get_cache_key() + + +@pytest.mark.parametrize('obj', [pylast.Artist(u'B\xe9l', mock_network())]) +def test_cast(obj): + assert type(six.text_type(obj)) is six.text_type diff --git a/tox.ini b/tox.ini index 21c569b..28796b2 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ deps = pyyaml pytest mock + ipdb pytest-cov commands = py.test -v --cov pylast --cov-report term-missing {posargs} @@ -27,4 +28,4 @@ commands = pyflakes tests pep8 pylast pep8 tests - clonedigger pylast -o /dev/stdout | grep -E "Clones detected\|lines are duplicates" \ No newline at end of file + ./clonedigger.sh \ No newline at end of file From 3f9137a7002a8ef2c1dc312d48ea514632806ec7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 8 Jan 2015 15:00:03 -0800 Subject: [PATCH 187/768] Version bump, fix setup.py, which was previously broken because excludes was not a tuple, so each character was being excluded and thus pylast the package was being excluded. --- pylast/__init__.py | 2 +- setup.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 88b7ccb..9c99afe 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -20,7 +20,7 @@ # # http://code.google.com/p/pylast/ -__version__ = '1.0.0' +__version__ = '1.0.1' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 2d58901..f8657e4 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.0.0", + version="1.0.1", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], @@ -25,9 +25,9 @@ setup( "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", - ], + ], keywords=["Last.fm", "music", "scrobble", "scrobbling"], - packages=find_packages(exclude=('tests*')), + packages=find_packages(exclude=('tests*',)), license="Apache2" ) From de31fc33f6488169a5eb19816600c4670ec47bb5 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 8 Jan 2015 16:05:28 -0800 Subject: [PATCH 188/768] fix base object hasing. Closes #120 . --- .dir-locals.el | 2 +- pylast/__init__.py | 4 ++-- tests/unicode_test.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.dir-locals.el b/.dir-locals.el index 427ee57..1fe46b5 100644 --- a/.dir-locals.el +++ b/.dir-locals.el @@ -1 +1 @@ -((nil . ((pytest-global-name . "source secrets.sh && tox -e py34 --")))) +((nil . ((pytest-global-name . "source secrets.sh && tox -e py27,py34 --")))) diff --git a/pylast/__init__.py b/pylast/__init__.py index 9c99afe..38f3727 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1328,9 +1328,9 @@ class _BaseObject(object): def __hash__(self): # Convert any ints (or whatever) into strings - values = map(str, self._get_params().values()) + values = map(six.text_type, self._get_params().values()) - return hash(self.network) + hash(str(type(self)) + "".join( + return hash(self.network) + hash(six.text_type(type(self)) + "".join( list(self._get_params().keys()) + list(values) ).lower()) diff --git a/tests/unicode_test.py b/tests/unicode_test.py index d05c529..511ecd1 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -24,5 +24,6 @@ def test_get_cache_key(artist): @pytest.mark.parametrize('obj', [pylast.Artist(u'B\xe9l', mock_network())]) -def test_cast(obj): +def test_cast_and_hash(obj): assert type(six.text_type(obj)) is six.text_type + assert isinstance(hash(obj), int) From 159925ff086431a57868c1c7dee0c302fe99e550 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 8 Jan 2015 23:13:38 -0800 Subject: [PATCH 189/768] remove dir-locals. make clonedigger always exit 0 --- .dir-locals.el | 1 - .gitignore | 2 ++ clonedigger.sh | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 .dir-locals.el diff --git a/.dir-locals.el b/.dir-locals.el deleted file mode 100644 index 1fe46b5..0000000 --- a/.dir-locals.el +++ /dev/null @@ -1 +0,0 @@ -((nil . ((pytest-global-name . "source secrets.sh && tox -e py27,py34 --")))) diff --git a/.gitignore b/.gitignore index 23b3b3f..60b5c14 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ docs/_build/ test_pylast.yaml lastfm.txt.pkl secrets.sh + +.dir-locals.el \ No newline at end of file diff --git a/clonedigger.sh b/clonedigger.sh index 14a016f..1c80b8d 100755 --- a/clonedigger.sh +++ b/clonedigger.sh @@ -1,3 +1,4 @@ #!/usr/bin/env bash clonedigger pylast -! grep -E "Clones detected\|lines are duplicates" output.html +grep -E "Clones detected\|lines are duplicates" output.html +exit 0 From b24417ea562c40886c4732f1234274a9d9641f00 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 8 Jan 2015 23:23:29 -0800 Subject: [PATCH 190/768] fix grep filter for clonedigger. --- clonedigger.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clonedigger.sh b/clonedigger.sh index 1c80b8d..96dc493 100755 --- a/clonedigger.sh +++ b/clonedigger.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash clonedigger pylast -grep -E "Clones detected\|lines are duplicates" output.html +grep -E "Clones detected|lines are duplicates" output.html exit 0 From 959622fcf2c6e6b3fb7fa7cfb9b4d74414ac7453 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 9 Jan 2015 10:38:06 +0200 Subject: [PATCH 191/768] Remove unused .build file --- .build | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .build diff --git a/.build b/.build deleted file mode 100644 index c227083..0000000 --- a/.build +++ /dev/null @@ -1 +0,0 @@ -0 \ No newline at end of file From 925ea840c43158ce2b2db1043e20ccb9dcd52649 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 9 Jan 2015 12:53:43 +0200 Subject: [PATCH 192/768] Update testing instructions [CI skip] --- README.md | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d089d68..5d22d6b 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,10 @@ Try using the pydoc utility for help on usage or see [test_pylast.py](test_pylas Installation ------------ -The easiest way is via pip: +Install via pip: pip install pylast -Or copy [pylast.py](pylast.py) to somewhere your Python can see it. No other dependencies are needed. - Features -------- @@ -27,7 +25,6 @@ Features * Full object-oriented design. * Proxy support. * Internal caching support for some web services calls (disabled by default). - * No extra dependencies but Python itself. * Support for other API-compatible networks like Libre.fm. * Python 3-friendly (Starting from 0.5). @@ -70,9 +67,9 @@ More examples in hugovk/lastfm- Testing ------- -[test_pylast.py](test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. +[tests/test_pylast.py](tests/test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. Unit tests are also in the [tests/](tests/) directory. -You need a test account at Last.fm that will be cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: +For integration tests you need a test account at Last.fm that will be cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE @@ -81,31 +78,21 @@ export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` -To run all: +To run all unit and integration tests: ```sh -pip install -r test_requirements.txt -./test_pylast.py +pip install pytest +py.test ``` -Or run just one: +Or run just one test case: ```sh -./test_pylast.py -1 test_scrobble -``` - -Or all those tests matching a term: -```sh -./test_pylast.py -m geo +py.test -k test_scrobble ``` To run with coverage: ```sh -coverage run --source=pylast ./test_pylast.py +py.test -v --cov pylast --cov-report term-missing coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html ``` - -To perform some static analysis: -```sh -./check.sh -``` From df36b178df50c9a2e15f45ef00d1c9ee8fb53a6a Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 11 Jan 2015 13:08:32 +0200 Subject: [PATCH 193/768] Fix pytest.py link [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5d22d6b..36bd8f5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ pyLast A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). -Try using the pydoc utility for help on usage or see [test_pylast.py](test_pylast.py) for examples. +Try using the pydoc utility for help on usage or see [test_pylast.py](tests/test_pylast.py) for examples. Installation ------------ From a9340e73f0bea504f17f8408de4c2a4e34e3fd1f Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 11 Jan 2015 16:42:53 +0200 Subject: [PATCH 194/768] Release 1.1.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 38f3727..c0d700b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -20,7 +20,7 @@ # # http://code.google.com/p/pylast/ -__version__ = '1.0.1' +__version__ = '1.1.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index f8657e4..71a0dda 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.0.1", + version="1.1.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 38d204ad90c5977167059d7efccfd2457c8dadd0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 25 Feb 2015 15:03:54 +0200 Subject: [PATCH 195/768] Release checklist --- RELEASING.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 RELEASING.md diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..9715b5e --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,26 @@ +# Release Checklist + +* [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. +* [ ] Update version in `pylast/__init__.py` and `setup.py` and commit: +```bash +git add pylast/__init__.py setup.py +git commit -m "Release 1.1.0" +``` +* [ ] Tag the last commit with the version number: +```bash +git tag -a 1.1.0 +``` +* [ ] Release on PyPI: +```bash +python setup.py register +python setup.py sdist --format=gztar upload +``` +* [ ] Push: `git push` +* [ ] Push tags: `git push --tags` +* [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new +* [ ] Update develop branch from master: +```bash +git checkout develop +git merge master --ff-only +git push +``` From 816be1cbe8cd50c7775cb61ac8fe9ad8d95a1a93 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 12:54:22 +0300 Subject: [PATCH 196/768] Rerun flaky interation tests --- README.md | 2 +- tests/test_pylast.py | 2 ++ tox.ini | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 36bd8f5..77b16eb 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh -pip install pytest +pip install pytest flaky py.test ``` diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 37695bf..8962ef3 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +from flaky import flaky import os import pytest from random import choice @@ -30,6 +31,7 @@ def load_secrets(): return doc +@flaky class TestPyLast(unittest.TestCase): secrets = None diff --git a/tox.ini b/tox.ini index 28796b2..ec0e215 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ deps = mock ipdb pytest-cov + flaky commands = py.test -v --cov pylast --cov-report term-missing {posargs} [testenv:venv] @@ -28,4 +29,4 @@ commands = pyflakes tests pep8 pylast pep8 tests - ./clonedigger.sh \ No newline at end of file + ./clonedigger.sh From 4f2871f504afa4fb0041c481dfa877255f88f7ea Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 15:47:00 +0300 Subject: [PATCH 197/768] pep8 fixes --- pylast/__init__.py | 45 ++++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index c0d700b..f7fe250 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -20,12 +20,6 @@ # # http://code.google.com/p/pylast/ -__version__ = '1.1.0' -__author__ = 'Amr Hassan, hugovk' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" -__license__ = "apache2" -__email__ = 'amr.hassan@gmail.com' - import hashlib from xml.dom import minidom import xml.dom @@ -36,9 +30,14 @@ import sys import collections import warnings import re - import six +__version__ = '1.1.0' +__author__ = 'Amr Hassan, hugovk' +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" +__license__ = "apache2" +__email__ = 'amr.hassan@gmail.com' + def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) @@ -834,13 +833,13 @@ class LastFMNetwork(_Network): name="Last.fm", homepage="http://last.fm", ws_server=("ws.audioscrobbler.com", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://post.audioscrobbler.com:80/", - username = username, - password_hash = password_hash, - domain_names = { + api_key=api_key, + api_secret=api_secret, + session_key=session_key, + submission_server="http://post.audioscrobbler.com:80/", + username=username, + password_hash=password_hash, + domain_names={ DOMAIN_ENGLISH: 'www.last.fm', DOMAIN_GERMAN: 'www.lastfm.de', DOMAIN_SPANISH: 'www.lastfm.es', @@ -854,7 +853,7 @@ class LastFMNetwork(_Network): DOMAIN_JAPANESE: 'www.lastfm.jp', DOMAIN_CHINESE: 'cn.last.fm', }, - urls = { + urls={ "album": "music/%(artist)s/%(album)s", "artist": "music/%(artist)s", "event": "event/%(id)s", @@ -930,13 +929,13 @@ class LibreFMNetwork(_Network): name="Libre.fm", homepage="http://alpha.libre.fm", ws_server=("alpha.libre.fm", "/2.0/"), - api_key = api_key, - api_secret = api_secret, - session_key = session_key, - submission_server = "http://turtle.libre.fm:80/", - username = username, - password_hash = password_hash, - domain_names = { + api_key=api_key, + api_secret=api_secret, + session_key=session_key, + submission_server="http://turtle.libre.fm:80/", + username=username, + password_hash=password_hash, + domain_names={ DOMAIN_ENGLISH: "alpha.libre.fm", DOMAIN_GERMAN: "alpha.libre.fm", DOMAIN_SPANISH: "alpha.libre.fm", @@ -950,7 +949,7 @@ class LibreFMNetwork(_Network): DOMAIN_JAPANESE: "alpha.libre.fm", DOMAIN_CHINESE: "alpha.libre.fm", }, - urls = { + urls={ "album": "artist/%(artist)s/album/%(album)s", "artist": "artist/%(artist)s", "event": "event/%(id)s", From 0b691de78938826709d98545845c58c51f645f9c Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 15:47:22 +0300 Subject: [PATCH 198/768] Remove unused import --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 71a0dda..2173f52 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,4 @@ #!/usr/bin/env python -import os - from setuptools import setup, find_packages From 16f12a02c90d8bb5b522cfe0447bd0c3ad8179a0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 16:06:34 +0300 Subject: [PATCH 199/768] Run fast lint job first --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5c0f5be..2cfaab8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,12 +10,12 @@ env: - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y= - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= matrix: + - TOXENV=lint - TOXENV=py27 - TOXENV=py33 - TOXENV=py34 - TOXENV=pypy - TOXENV=pypy3 - - TOXENV=lint sudo: false install: - travis_retry pip install tox From 404f48be1e6e5adfdd3258f6d3dd5d6b71f5600d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 12 Apr 2015 19:23:32 +0300 Subject: [PATCH 200/768] Badges: master -> develop, hugovk -> pylast [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 77b16eb..d95d09d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.png?branch=master)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=master)](https://coveralls.io/r/pylast/pylast?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/hugovk/pylast/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/hugovk/pylast/?branch=master) [![Code Health](https://landscape.io/github/hugovk/pylast/master/landscape.svg)](https://landscape.io/github/hugovk/pylast/master) +[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/pylast/pylast/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/pylast/pylast/?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From a6f209a3f91c0b37afa048935b81b043e778970b Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 19:37:53 +0300 Subject: [PATCH 201/768] Remove unused argument --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f7fe250..da5cb5e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4038,7 +4038,7 @@ def _extract(node, name, index=0): return None -def _extract_element_tree(node, index=0): +def _extract_element_tree(node): """Extract an element tree into a multi-level dictionary NB: If any elements have text nodes as well as nested From f13c94abe8cec029bc569b8fdb0c4a63cada7139 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 19:39:19 +0300 Subject: [PATCH 202/768] Don't redefine built-in --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index da5cb5e..6076d42 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4197,13 +4197,13 @@ class BadSessionError(ScrobblingError): class _ScrobblerRequest(object): - def __init__(self, url, params, network, type="POST"): + def __init__(self, url, params, network, request_type="POST"): for key in params: params[key] = str(params[key]) self.params = params - self.type = type + self.type = request_type (self.hostname, self.subdir) = url_split_host(url[len("http:"):]) self.network = network From b36762b0f0e6182449b3bb02c48f6d25da29740d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 12 Apr 2015 19:51:11 +0300 Subject: [PATCH 203/768] Add licence [CI skip] --- LICENSE.txt | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE.txt diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 4597514317b8ad9b983f4e3b9cfcce838ca4aaf1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 12 Apr 2015 19:55:48 +0300 Subject: [PATCH 204/768] 2015 --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 6076d42..fe6873e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4,7 +4,7 @@ # A Python interface to Last.fm (and other API compatible social networks) # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2014 hugovk +# Copyright 2013-2015 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ import six __version__ = '1.1.0' __author__ = 'Amr Hassan, hugovk' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2014 hugovk" +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' From be8ae40caf444a0af8a5f1a986783fef99f281ea Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 26 Apr 2015 20:10:24 +0300 Subject: [PATCH 205/768] Failing test for #124 --- tests/test_pylast.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 8962ef3..e3581b9 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1101,6 +1101,14 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + def test_network_get_top_tags_with_no_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags() + + # Assert + self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) + def test_network_get_top_tracks_with_limit(self): # Arrange # Act From 926c01a97e9ca6c82e948d20cbc068f75591bc64 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 26 Apr 2015 20:11:49 +0300 Subject: [PATCH 206/768] Don't compare with limit if limit is None. Fixes #124. --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fe6873e..51bb5a1 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -396,7 +396,7 @@ class _Network(object): seq = [] for node in doc.getElementsByTagName("tag"): - if len(seq) >= limit: + if limit and len(seq) >= limit: break tag = Tag(_extract(node, "name"), self) weight = _number(_extract(node, "count")) From 97ec6f84fc72c35d7ae84838a1ce8f565e18ebc1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 26 Apr 2015 20:14:09 +0300 Subject: [PATCH 207/768] Add EditorConfig --- .editorconfig | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de5533f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +# 4 space indentation +[*.py] +indent_size = 4 +indent_style = space From 88a74273a0d852ae3b7888c2017849305caeef22 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 26 Apr 2015 21:26:21 +0300 Subject: [PATCH 208/768] Sometimes Last.fm returns an error for dates[1]: No chart available for this user/date-range combination --- tests/test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index e3581b9..ee14679 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1236,7 +1236,7 @@ class TestPyLast(unittest.TestCase): self.helper_dates_valid(dates) # Act/Assert - self.helper_get_assert_charts(lastfm_user, dates[1]) + self.helper_get_assert_charts(lastfm_user, dates[0]) def test_track_top_fans(self): # Arrange From e26b542bec72a70ce53a5eb26a07bdeae08024e5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 26 Apr 2015 22:18:58 +0300 Subject: [PATCH 209/768] Retry failures up to four times, require at least one pass --- tests/test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index ee14679..1574d13 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -31,7 +31,7 @@ def load_secrets(): return doc -@flaky +@flaky(max_runs=5, min_passes=1) class TestPyLast(unittest.TestCase): secrets = None From 5dcf8a2f70bc46ddf684abacc94033897c745333 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 27 Apr 2015 21:21:20 +0300 Subject: [PATCH 210/768] Remove commented function --- pylast/__init__.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 51bb5a1..76459ee 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -192,19 +192,6 @@ class _Network(object): self.session_key = sk_gen.get_session_key( self.username, self.password_hash) - """def __repr__(self): - attributes = ("name", "homepage", "ws_server", "api_key", "api_secret", - "session_key", "submission_server", "username", "password_hash", - "domain_names", "urls") - - text = "pylast._Network(%s)" - args = [] - for attr in attributes: - args.append("=".join((attr, repr(getattr(self, attr))))) - - return text % ", ".join(args) - """ - def __str__(self): return "%s Network" % self.name From 615bccb227bcf67b7b95873e6c11df63608df678 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 27 Apr 2015 21:41:16 +0300 Subject: [PATCH 211/768] Remove artist.getImages and depracation note --- pylast/__init__.py | 8 -------- tests/test_pylast.py | 13 ------------- 2 files changed, 21 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 76459ee..45d627b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2077,14 +2077,6 @@ class Artist(_BaseObject, _Taggable): return self.network._get_url( domain_name, "artist") % {'artist': artist} - def get_images(self, order=IMAGES_ORDER_POPULARITY, limit=None): - """ - The artist.getImages method has been deprecated by Last.fm. - """ - raise WSError( - self.network, "27", - "The artist.getImages method has been deprecated by Last.fm.") - def shout(self, message): """ Post a shout diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 1574d13..7a3f736 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -36,11 +36,6 @@ class TestPyLast(unittest.TestCase): secrets = None - # To remove Python 3's - # "DeprecationWarning: Please use assertRaisesRegex instead" - if sys.version_info[0] == 2: - assertRaisesRegex = unittest.TestCase.assertRaisesRegexp - def unix_timestamp(self): return int(time.time()) @@ -762,14 +757,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(name, "Last.fm Network") - def test_artist_get_images_deprecated(self): - # Arrange - artist = self.network.get_artist("Test Artist") - - # Act/Assert - with self.assertRaisesRegex(pylast.WSError, 'deprecated'): - artist.get_images() - def helper_validate_results(self, a, b, c): # Assert self.assertIsNotNone(a) From 4437508e0f5571a72312a881923ddca1d42c9638 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 28 Apr 2015 23:14:36 +0300 Subject: [PATCH 212/768] Remove unused import --- tests/test_pylast.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 7a3f736..1980e7a 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -6,7 +6,6 @@ from flaky import flaky import os import pytest from random import choice -import sys import time import unittest From 67e0e80159886a2ec8df9b4ae2eb6cf04982f953 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 May 2015 08:23:02 +0300 Subject: [PATCH 213/768] Release 1.2.0 --- pylast/__init__.py | 6 +++--- setup.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 45d627b..172a537 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # pylast - -# A Python interface to Last.fm (and other API compatible social networks) +# A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan # Copyright 2013-2015 hugovk @@ -18,7 +18,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# http://code.google.com/p/pylast/ +# https://github.com/pylast/pylast import hashlib from xml.dom import minidom @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.1.0' +__version__ = '1.2.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 2173f52..8e80ef6 100755 --- a/setup.py +++ b/setup.py @@ -4,12 +4,11 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.1.0", + version="1.2.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], - description=("A Python interface to Last.fm " - "(and other API compatible social networks)"), + description=("A Python interface to Last.fm and Libre.fm"), author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", classifiers=[ From 767ff35ff90c4222e145524c725d4ec924651623 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 May 2015 09:26:44 +0300 Subject: [PATCH 214/768] Update release checklist --- RELEASING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9715b5e..8b6ca48 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,11 +4,11 @@ * [ ] Update version in `pylast/__init__.py` and `setup.py` and commit: ```bash git add pylast/__init__.py setup.py -git commit -m "Release 1.1.0" +git commit -m "Release 1.3.0" ``` * [ ] Tag the last commit with the version number: ```bash -git tag -a 1.1.0 +git tag -a 1.3.0 -m "Release 1.3.0" ``` * [ ] Release on PyPI: ```bash From bf24aadfff27f9feb930c172c011fb85a3c0975a Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 May 2015 09:34:43 +0300 Subject: [PATCH 215/768] Remember to release on master --- RELEASING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASING.md b/RELEASING.md index 8b6ca48..9cd8f8a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,6 +3,8 @@ * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. * [ ] Update version in `pylast/__init__.py` and `setup.py` and commit: ```bash +git checkout master +edit pylast/__init__.py setup.py git add pylast/__init__.py setup.py git commit -m "Release 1.3.0" ``` From 23223d1fe862f4369ee17bfe18bb3b3e5e76fa1f Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 May 2015 08:23:02 +0300 Subject: [PATCH 216/768] Release 1.2.0 --- pylast/__init__.py | 6 +++--- setup.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 45d627b..172a537 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # pylast - -# A Python interface to Last.fm (and other API compatible social networks) +# A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan # Copyright 2013-2015 hugovk @@ -18,7 +18,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# http://code.google.com/p/pylast/ +# https://github.com/pylast/pylast import hashlib from xml.dom import minidom @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.1.0' +__version__ = '1.2.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 2173f52..8e80ef6 100755 --- a/setup.py +++ b/setup.py @@ -4,12 +4,11 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.1.0", + version="1.2.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], - description=("A Python interface to Last.fm " - "(and other API compatible social networks)"), + description=("A Python interface to Last.fm and Libre.fm"), author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", classifiers=[ From b47c5e587d5e5b3380827605f66c956fa3e03508 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 May 2015 10:20:53 +0300 Subject: [PATCH 217/768] Release 1.2.1 --- MANIFEST.in | 4 ++-- pylast/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 022f2b4..c6f00e6 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ -include pylast.py +include pylast/__init__.py include setup.py -include README +include README.md include COPYING include INSTALL diff --git a/pylast/__init__.py b/pylast/__init__.py index 172a537..50d40c7 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.2.0' +__version__ = '1.2.1' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 8e80ef6..adab46d 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.2.0", + version="1.2.1", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 1a46ad2cb37caeaa036130de0df0124b4d320429 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 16 Jun 2015 23:20:36 +0300 Subject: [PATCH 218/768] Replace broken pypip.in badges with shields.io [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d95d09d..f936d12 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://pypip.in/version/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://pypip.in/download/pylast/badge.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/pylast/pylast/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/pylast/pylast/?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) +[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/pylast/pylast/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/pylast/pylast/?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From 941dd0123bdeb951f5322dc6161adce04a0fdc28 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 16 Jun 2015 23:21:42 +0300 Subject: [PATCH 219/768] Remove useless Scrutinizer badge [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f936d12..323dec4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/pylast/pylast/badges/quality-score.png?b=develop)](https://scrutinizer-ci.com/g/pylast/pylast/?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) +[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). From c8f8b60ec75a4cef479ad02806b743051c6ee935 Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Thu, 25 Jun 2015 21:43:12 +0200 Subject: [PATCH 220/768] Support for User.GetPersonalTags Added functions User.get_tagged_albums, ..._tracks and ..._artists to support User.GetPersonalTags. --- pylast/__init__.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 50d40c7..7f25b27 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.2.1' +__version__ = '1.2.2' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" @@ -3505,6 +3505,43 @@ class User(_BaseObject, _Chartable): return doc.getElementsByTagName( "registered")[0].getAttribute("unixtime") + def get_tagged_albums(self, tag, limit=None, cacheable=True): + """Returns the albums tagged by a user.""" + + params = self._get_params() + params['tag'] = tag + params['taggingtype'] = 'album' + if limit: + params['limit'] = limit + + doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, params) + + return _extract_top_albums(doc, self.network) + + def get_tagged_artists(self, tag, limit=None): + """Returns the albums artists tagged by a user.""" + + params = self._get_params() + params['tag'] = tag + params['taggingtype'] = 'artist' + if limit: + params["limit"] = limit + + doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) + + return _extract_top_artists(doc, self.network) + + def get_tagged_tracks(self, tag, limit=None, cacheable=True): + """Returns the tracks tagged by a user.""" + + params = self._get_params() + params['tag'] = tag + params['taggingtype'] = 'track' + if limit: + params['limit'] = limit + + return self._get_things("getpersonaltags", "track", Track, params, cacheable) + def get_top_albums( self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top albums played by a user. From 658ed7a102667b07c9b49c254cddf7ad396c632d Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Thu, 25 Jun 2015 21:57:33 +0200 Subject: [PATCH 221/768] Cleanup of User.GetPersonalTags stuff --- pylast/__init__.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 7f25b27..efd8c24 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3516,7 +3516,7 @@ class User(_BaseObject, _Chartable): doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, params) - return _extract_top_albums(doc, self.network) + return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): """Returns the albums artists tagged by a user.""" @@ -3529,7 +3529,7 @@ class User(_BaseObject, _Chartable): doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) - return _extract_top_artists(doc, self.network) + return _extract_artists(doc, self.network) def get_tagged_tracks(self, tag, limit=None, cacheable=True): """Returns the tracks tagged by a user.""" @@ -3540,7 +3540,7 @@ class User(_BaseObject, _Chartable): if limit: params['limit'] = limit - return self._get_things("getpersonaltags", "track", Track, params, cacheable) + return _extract_tracks(doc, self.network) def get_top_albums( self, period=PERIOD_OVERALL, limit=None, cacheable=True): @@ -4123,7 +4123,30 @@ def _extract_top_albums(doc, network): return seq +def _extract_artists(doc, network): + seq = [] + for node in doc.getElementsByTagName("artist"): + seq.append(Artist(_extract(node, "name"), network)) + return seq + +def _extract_albums(doc, network): + seq = [] + for node in doc.getElementsByTagName("album"): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + seq.append(Album(artist, name, network)) + return seq + +def _extract_tracks(doc, network): + seq = [] + for node in doc.getElementsByTagName("track"): + name = _extract(node, "name") + artist = _extract(node, "name", 1) + seq.append(Track(artist, name, network)) + return seq + + def _extract_events_from_doc(doc, network): events = [] for node in doc.getElementsByTagName("event"): From 4379e28c19d24b8d7beb1dd27755688224b8fe4f Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Fri, 26 Jun 2015 01:35:51 +0200 Subject: [PATCH 222/768] Fixed broken comment --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index efd8c24..05b7de8 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3730,7 +3730,7 @@ class AuthenticatedUser(User): def get_recommended_artists(self, limit=50, cacheable=False): """ - Returns a sequence of Event objects + Returns a sequence of Artist objects if limit==None it will return all """ From 8b321dfe748cb9e565d45f895fef28f4a92dab08 Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Sat, 27 Jun 2015 00:11:08 +0200 Subject: [PATCH 223/768] Added User.GetPersonalTags test cases --- tests/test_pylast.py | 46 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 1980e7a..664b118 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1040,7 +1040,13 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(things, list) self.assertIsInstance(things[0], pylast.TopItem) self.assertIsInstance(things[0].item, expected_type) - + + def helper_only_one_thing_in_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 1) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], expected_type) + def helper_two_different_things_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 2) @@ -1399,7 +1405,45 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_user_tagged_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["artisttagola"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + + # Act + artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(artists, pylast.Artist) + def test_user_tagged_albums(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["albumtagola"] + album = self.network.get_album("Test Artist", "Test Album") + album.add_tags(tags) + + # Act + albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(albums, pylast.Album) + + def test_user_tagged_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["tracktagola"] + track = self.network.get_track("Test Artist", "Test Title") + track.add_tags(tags) + # Act + tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(tracks, pylast.Track) + def test_caching(self): # Arrange user = self.network.get_user("RJ") From aa5f00bbd67821705089f16f18351f3f319c51d0 Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Sat, 27 Jun 2015 00:11:40 +0200 Subject: [PATCH 224/768] User.GetPersonalTags bug fix get_tagged_tracks crashed --- pylast/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 05b7de8..98888f0 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3513,9 +3513,7 @@ class User(_BaseObject, _Chartable): params['taggingtype'] = 'album' if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, params) - return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): @@ -3526,9 +3524,7 @@ class User(_BaseObject, _Chartable): params['taggingtype'] = 'artist' if limit: params["limit"] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) - return _extract_artists(doc, self.network) def get_tagged_tracks(self, tag, limit=None, cacheable=True): @@ -3539,7 +3535,7 @@ class User(_BaseObject, _Chartable): params['taggingtype'] = 'track' if limit: params['limit'] = limit - + doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) return _extract_tracks(doc, self.network) def get_top_albums( From 51b0f3b11a47e0363716501a691116a24833f358 Mon Sep 17 00:00:00 2001 From: brtkrbzhnv Date: Sun, 28 Jun 2015 11:17:13 +0200 Subject: [PATCH 225/768] Fixed broken comment --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 98888f0..aea4880 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3517,7 +3517,7 @@ class User(_BaseObject, _Chartable): return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): - """Returns the albums artists tagged by a user.""" + """Returns the artists tagged by a user.""" params = self._get_params() params['tag'] = tag @@ -4461,4 +4461,4 @@ class Scrobbler(object): if remainder: self.scrobble_many(remainder) -# End of file +# End of file \ No newline at end of file From 71d59a17cac78d7b197d3e6b893b3934f80340c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Wed, 1 Jul 2015 16:57:44 -0300 Subject: [PATCH 226/768] Add suppport for artist.getCorrection --- pylast/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index 50d40c7..5cc1507 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1928,6 +1928,12 @@ class Artist(_BaseObject, _Taggable): return self.name + def get_correction(self): + """Returns the corrected artist name.""" + + return _extract( + self._request(self.ws_prefix + ".getCorrection"), "name") + def get_cover_image(self, size=COVER_MEGA): """ Returns a uri to the cover image From db9bc4f216b4fddcea44863d445979b350d13ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Wed, 1 Jul 2015 17:00:48 -0300 Subject: [PATCH 227/768] Add suppport for track.getCorrection --- pylast/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index 5cc1507..4313496 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2952,6 +2952,12 @@ class Track(_Opus): def __init__(self, artist, title, network, username=None): super(Track, self).__init__(artist, title, network, "track", username) + + def get_correction(self): + """Returns the corrected track name.""" + + return _extract( + self._request(self.ws_prefix + ".getCorrection"), "name") def get_duration(self): """Returns the track duration.""" From 6b58f43c01599c65e81ddb0d910179689662a2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Wed, 1 Jul 2015 17:04:01 -0300 Subject: [PATCH 228/768] Add test case for Artist.get_correction() --- tests/test_pylast.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 1980e7a..c388ef9 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1907,7 +1907,16 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tracks), 1) self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - + + def test_artist_get_correction(self): + # Arrange + artist = pylast.Artist("guns and roses", self.network) + + # Act + corrected_artist_name = artist.get_correction() + + # Assert + self.assertEqual(corrected_artist_name, "Guns N' Roses") if __name__ == '__main__': unittest.main(failfast=True) From 5f18619333011ebc4fe970d1e8f9b8d4cd983291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Wed, 1 Jul 2015 17:04:43 -0300 Subject: [PATCH 229/768] Add test case for Track.get_correction() --- tests/test_pylast.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index c388ef9..1f3ae40 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1917,6 +1917,16 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") + + def test_track_get_correction(self): + # Arrange + track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) + + # Act + corrected_track_name = track.get_correction() + + # Assert + self.assertEqual(corrected_track_name, "Mr. Brownstone") if __name__ == '__main__': unittest.main(failfast=True) From 2aaddbd81b31119966a3f19cb40e771b9efcefcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Thu, 9 Jul 2015 16:56:09 -0300 Subject: [PATCH 230/768] pep8 compliance --- pylast/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4313496..f593667 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1930,7 +1930,7 @@ class Artist(_BaseObject, _Taggable): def get_correction(self): """Returns the corrected artist name.""" - + return _extract( self._request(self.ws_prefix + ".getCorrection"), "name") @@ -2952,12 +2952,12 @@ class Track(_Opus): def __init__(self, artist, title, network, username=None): super(Track, self).__init__(artist, title, network, "track", username) - + def get_correction(self): """Returns the corrected track name.""" - + return _extract( - self._request(self.ws_prefix + ".getCorrection"), "name") + self._request(self.ws_prefix + ".getCorrection"), "name") def get_duration(self): """Returns the track duration.""" From 2f72ec827060da8989c6dae6365ce3f61daf44f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Neto?= Date: Thu, 9 Jul 2015 17:02:53 -0300 Subject: [PATCH 231/768] pep8 compliance --- tests/test_pylast.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 1f3ae40..25a9596 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1907,24 +1907,24 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tracks), 1) self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - + def test_artist_get_correction(self): # Arrange artist = pylast.Artist("guns and roses", self.network) - + # Act corrected_artist_name = artist.get_correction() - + # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") - + def test_track_get_correction(self): # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) - + # Act corrected_track_name = track.get_correction() - + # Assert self.assertEqual(corrected_track_name, "Mr. Brownstone") From 3240a54e323eaf82dd1b1b78b5b54c0ca860d94b Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 24 Jul 2015 11:01:34 +0300 Subject: [PATCH 232/768] flake8 --- pylast/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index aea4880..253013b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3513,7 +3513,8 @@ class User(_BaseObject, _Chartable): params['taggingtype'] = 'album' if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, params) + doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, + params) return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): @@ -3526,7 +3527,7 @@ class User(_BaseObject, _Chartable): params["limit"] = limit doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) return _extract_artists(doc, self.network) - + def get_tagged_tracks(self, tag, limit=None, cacheable=True): """Returns the tracks tagged by a user.""" @@ -4119,6 +4120,7 @@ def _extract_top_albums(doc, network): return seq + def _extract_artists(doc, network): seq = [] for node in doc.getElementsByTagName("artist"): @@ -4134,6 +4136,7 @@ def _extract_albums(doc, network): seq.append(Album(artist, name, network)) return seq + def _extract_tracks(doc, network): seq = [] for node in doc.getElementsByTagName("track"): @@ -4141,8 +4144,8 @@ def _extract_tracks(doc, network): artist = _extract(node, "name", 1) seq.append(Track(artist, name, network)) return seq - - + + def _extract_events_from_doc(doc, network): events = [] for node in doc.getElementsByTagName("event"): @@ -4461,4 +4464,4 @@ class Scrobbler(object): if remainder: self.scrobble_many(remainder) -# End of file \ No newline at end of file +# End of file From c421fd5cbd5e09d64dbbaaaba9c8863138171be5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 24 Jul 2015 11:22:39 +0300 Subject: [PATCH 233/768] flake8 --- tests/test_pylast.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 664b118..7deb4db 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1040,13 +1040,13 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(things, list) self.assertIsInstance(things[0], pylast.TopItem) self.assertIsInstance(things[0].item, expected_type) - + def helper_only_one_thing_in_list(self, things, expected_type): # Assert self.assertEqual(len(things), 1) self.assertIsInstance(things, list) self.assertIsInstance(things[0], expected_type) - + def helper_two_different_things_in_top_list(self, things, expected_type): # Assert self.assertEqual(len(things), 2) @@ -1405,17 +1405,17 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) - + def test_user_tagged_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) tags = ["artisttagola"] artist = self.network.get_artist("Test Artist") artist.add_tags(tags) - + # Act artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) - + # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) @@ -1425,10 +1425,10 @@ class TestPyLast(unittest.TestCase): tags = ["albumtagola"] album = self.network.get_album("Test Artist", "Test Album") album.add_tags(tags) - + # Act albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) - + # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) @@ -1440,10 +1440,10 @@ class TestPyLast(unittest.TestCase): track.add_tags(tags) # Act tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) - + # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) - + def test_caching(self): # Arrange user = self.network.get_user("RJ") From 6264b4fecc27271af847f44c92e3eb2512c65e83 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 24 Jul 2015 11:26:36 +0300 Subject: [PATCH 234/768] get_tagged_tracks: Use cacheable param --- pylast/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 253013b..6ba0eb7 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3536,7 +3536,8 @@ class User(_BaseObject, _Chartable): params['taggingtype'] = 'track' if limit: params['limit'] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) + doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, + params) return _extract_tracks(doc, self.network) def get_top_albums( From dae6a276ea0404095d4814f3fff475abc11d2889 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Aug 2015 13:54:21 +0300 Subject: [PATCH 235/768] Let Tox use these env vars --- tox.ini | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tox.ini b/tox.ini index ec0e215..b5dd199 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,11 @@ recreate = False [testenv] downloadcache = {homedir}/.pipcache +setenv = + PYLAST_USERNAME={env:PYLAST_USERNAME:} + PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:} + PYLAST_API_KEY={env:PYLAST_API_KEY:} + PYLAST_API_SECRET={env:PYLAST_API_SECRET:} deps = pyyaml pytest From 2faaf13bfa366d7f4d9d84ff7fe1c9fc278a899a Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 3 Aug 2015 20:37:42 +0300 Subject: [PATCH 236/768] Release 1.3.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 33e19cd..f49aab0 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.2.2' +__version__ = '1.3.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index adab46d..aa93c17 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.2.1", + version="1.3.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From fb10407a70fb6b86ef73257bfea1046676ee8db4 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 3 Sep 2015 15:59:02 +0300 Subject: [PATCH 237/768] Fix get_recent_tracks (and more) --- pylast/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f49aab0..fe9e2a1 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -21,7 +21,7 @@ # https://github.com/pylast/pylast import hashlib -from xml.dom import minidom +from xml.dom import minidom, Node import xml.dom import time import shelve @@ -4014,6 +4014,16 @@ def _string(string): return casted +def cleanup_nodes(doc): + """ + Remove text nodes containing only whitespace + """ + for node in doc.documentElement.childNodes: + if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace(): + doc.documentElement.removeChild(node) + return doc + + def _collect_nodes(limit, sender, method_name, cacheable, params=None): """ Returns a sequence of dom.Node objects about as close to limit as possible @@ -4029,8 +4039,9 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): while not end_of_pages and (not limit or (limit and len(nodes) < limit)): params["page"] = str(page) doc = sender._request(method_name, cacheable, params) + doc = cleanup_nodes(doc) - main = doc.documentElement.childNodes[1] + main = doc.documentElement.childNodes[0] if main.hasAttribute("totalPages"): total_pages = _number(main.getAttribute("totalPages")) From 94e8e6f7adee5d4a105499e75babae3bcf561474 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 5 Sep 2015 10:09:19 +0300 Subject: [PATCH 238/768] Release 1.4.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f49aab0..3650470 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.3.0' +__version__ = '1.4.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index aa93c17..adbdc89 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.3.0", + version="1.4.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 072f0aca1a34d72e855dd0429cf9a96b1c42a138 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 5 Sep 2015 10:18:21 +0300 Subject: [PATCH 239/768] Add more detail and another step [CI skip] --- RELEASING.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 9cd8f8a..e4ed249 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,11 +6,11 @@ git checkout master edit pylast/__init__.py setup.py git add pylast/__init__.py setup.py -git commit -m "Release 1.3.0" +git commit -m "Release 1.5.0" ``` * [ ] Tag the last commit with the version number: ```bash -git tag -a 1.3.0 -m "Release 1.3.0" +git tag -a 1.5.0 -m "Release 1.5.0" ``` * [ ] Release on PyPI: ```bash @@ -20,9 +20,12 @@ python setup.py sdist --format=gztar upload * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new + * Tag: Pick existing tag "1.5.0" + * Title: "Release 1.5.0" * [ ] Update develop branch from master: ```bash git checkout develop git merge master --ff-only git push ``` + * [ ] Check installation: `pip install -U pylast` From ab968a3fe376564698327ddf5669f18d53539cc5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 5 Sep 2015 10:57:06 +0300 Subject: [PATCH 240/768] Release 1.4.1 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 3650470..c60fa3c 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.4.0' +__version__ = '1.4.1' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index adbdc89..5a29c34 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.4.0", + version="1.4.1", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From dc7528ab5c913bf6cf2e594f1b80539f9c183b9a Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Sep 2015 15:07:35 +0300 Subject: [PATCH 241/768] Release 1.4.2 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f621266..4382337 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.4.1' +__version__ = '1.4.2' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 5a29c34..3fb0404 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.4.1", + version="1.4.2", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From f9600268eae551ee395e4ccb2e10681cacf6772f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 7 Sep 2015 15:41:30 +0300 Subject: [PATCH 242/768] Check installation sooner [CI skip] --- RELEASING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index e4ed249..b395aaa 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -17,6 +17,7 @@ git tag -a 1.5.0 -m "Release 1.5.0" python setup.py register python setup.py sdist --format=gztar upload ``` +* [ ] Check installation: `pip install -U pylast` * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new @@ -28,4 +29,4 @@ git checkout develop git merge master --ff-only git push ``` - * [ ] Check installation: `pip install -U pylast` + From cbf2066ee57111a2e5a3f7c28fa0ce2110c1e477 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Sep 2015 18:25:32 +0300 Subject: [PATCH 243/768] Failing test for #146 --- tests/test_pylast.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index abe9917..e013811 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1972,5 +1972,15 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_track_name, "Mr. Brownstone") + def test_track_with_no_mbid(self): + # Arrange + track = pylast.Track("Static-X", "Set It Off", self.network) + + # Act + mbid = track.get_mbid() + + # Assert + self.assertEqual(mbid, None) + if __name__ == '__main__': unittest.main(failfast=True) From f090876c0aa3c8641f79d9acd10f5fa578eaed0f Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Sep 2015 23:57:36 +0300 Subject: [PATCH 244/768] Fix for #146: only get the top-level --- pylast/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4382337..4c5e28f 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1809,8 +1809,21 @@ class _Opus(_BaseObject, _Taggable): def get_mbid(self): """Returns the MusicBrainz ID of the album or track.""" - return _extract( - self._request(self.ws_prefix + ".getInfo", cacheable=True), "mbid") + doc = self._request(self.ws_prefix + ".getInfo", cacheable=True) + + try: + lfm = doc.getElementsByTagName('lfm')[0] + opus = self._get_children_by_tag_name(lfm, self.ws_prefix).next() + mbid = self._get_children_by_tag_name(opus, "mbid").next() + return mbid.firstChild.nodeValue + except StopIteration: + return None + + def _get_children_by_tag_name(self, node, tag_name): + for child in node.childNodes: + if (child.nodeType == child.ELEMENT_NODE and + (tag_name == '*' or child.tagName == tag_name)): + yield child class Album(_Opus): From 63eb4de06b3960898c76020c5a78c3df20f5267d Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 7 Sep 2015 15:07:35 +0300 Subject: [PATCH 245/768] Release 1.4.2 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f621266..4382337 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.4.1' +__version__ = '1.4.2' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 5a29c34..3fb0404 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.4.1", + version="1.4.2", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 6382853b45a006c07f7d9823fa18fdbf6fe71411 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 7 Sep 2015 15:41:30 +0300 Subject: [PATCH 246/768] Check installation sooner [CI skip] --- RELEASING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index e4ed249..b395aaa 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -17,6 +17,7 @@ git tag -a 1.5.0 -m "Release 1.5.0" python setup.py register python setup.py sdist --format=gztar upload ``` +* [ ] Check installation: `pip install -U pylast` * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new @@ -28,4 +29,4 @@ git checkout develop git merge master --ff-only git push ``` - * [ ] Check installation: `pip install -U pylast` + From 37fdc1fe806d018ce7d641a06c9e5af925b0189e Mon Sep 17 00:00:00 2001 From: yanggao1119 Date: Mon, 21 Sep 2015 10:07:22 -0700 Subject: [PATCH 247/768] replaced opensearch: as empty str --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4c5e28f..31f8a63 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1135,13 +1135,13 @@ class _Request(object): else: response = self._download_response() - return minidom.parseString(_string(response)) + return minidom.parseString(_string(response).replace("opensearch:", "")) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" try: - doc = minidom.parseString(_string(response)) + doc = minidom.parseString(_string(response).replace("opensearch:", "")) except Exception as e: raise MalformedResponseError(self.network, e) From 53be0e5f062786349d3a0a8b193bb2ad8be1ef2d Mon Sep 17 00:00:00 2001 From: Kaushik Ganesh Date: Tue, 1 Dec 2015 19:20:34 +0530 Subject: [PATCH 248/768] Fix album.get_tracks() --- pylast/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4c5e28f..ca65067 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1857,9 +1857,9 @@ class Album(_Opus): def get_tracks(self): """Returns the list of Tracks on this album.""" - uri = 'lastfm://playlist/album/%s' % self.get_id() - - return XSPF(uri, self.network).get_tracks() + return _extract_tracks( + self._request( + self.ws_prefix + ".getInfo", cacheable=True), "tracks") def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the album or track page on the network. From 5bb98a35411c2a1f15cd350e8136eae1de702be5 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 10 Dec 2015 00:44:17 +0200 Subject: [PATCH 249/768] Fix tox==2.1.1 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 2cfaab8..9a32908 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ env: - TOXENV=pypy3 sudo: false install: -- travis_retry pip install tox +- travis_retry pip install tox==2.1.1 - travis_retry pip install coveralls script: tox after_success: From 42e881824bb603d022a503f25a6cc170e8ed1ee0 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 10 Dec 2015 09:06:25 +0200 Subject: [PATCH 250/768] Add YAML rules [CI skip] --- .editorconfig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index de5533f..b71c07e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# top-most EditorConfig file +# Top-most EditorConfig file root = true # Unix-style newlines with a newline ending every file @@ -7,7 +7,13 @@ end_of_line = lf insert_final_newline = true charset = utf-8 -# 4 space indentation +# Four-space indentation [*.py] indent_size = 4 indent_style = space + +trim_trailing_whitespace = true + +# Two-space indentation +[*.yml] +indent_size = 2 From ba196dbb3bee165b9a190440b8055ebe9459842f Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 10 Dec 2015 20:47:17 +0200 Subject: [PATCH 251/768] Release 1.5.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4382337..6e56032 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.4.2' +__version__ = '1.5.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 3fb0404..b843f9e 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.4.2", + version="1.5.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 0938c596f82178486b8e0b5fae2e6043d49b174b Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 10 Dec 2015 21:08:46 +0200 Subject: [PATCH 252/768] Release 1.5.1 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 5b4ce0f..35835f8 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.5.0' +__version__ = '1.5.1' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index b843f9e..908c625 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.5.0", + version="1.5.1", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 5dcfdeb25595eb34dc47ad288aced701364d223f Mon Sep 17 00:00:00 2001 From: sfordinc Date: Wed, 23 Dec 2015 16:12:25 +0400 Subject: [PATCH 253/768] Update __init__.py add var PERIOD_1MONTHS --- pylast/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 35835f8..d075fd8 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -76,7 +76,8 @@ EVENT_MAYBE_ATTENDING = '1' EVENT_NOT_ATTENDING = '2' PERIOD_OVERALL = 'overall' -PERIOD_7DAYS = "7day" +PERIOD_7DAYS = '7day' +PERIOD_1MONTHS = '1month' PERIOD_3MONTHS = '3month' PERIOD_6MONTHS = '6month' PERIOD_12MONTHS = '12month' From 4e6888cea8bd99b450b432ded4a70bda81e465ca Mon Sep 17 00:00:00 2001 From: sfordinc Date: Wed, 23 Dec 2015 16:22:59 +0400 Subject: [PATCH 254/768] Update __init__.py --- pylast/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index d075fd8..fbba441 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3572,6 +3572,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS + o PERIOD_1MONTHS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS @@ -3592,6 +3593,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS + o PERIOD_1MONTHS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS @@ -3634,6 +3636,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS + o PERIOD_1MONTHS o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS From 3941b4b0fc587a6d6745b459489a849cb37ca015 Mon Sep 17 00:00:00 2001 From: sfordinc Date: Wed, 23 Dec 2015 16:26:44 +0400 Subject: [PATCH 255/768] Update __init__.py rename PERIOD_1MONTHS PERIOD_1MONTH --- pylast/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fbba441..c1879c7 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -77,7 +77,7 @@ EVENT_NOT_ATTENDING = '2' PERIOD_OVERALL = 'overall' PERIOD_7DAYS = '7day' -PERIOD_1MONTHS = '1month' +PERIOD_1MONTH = '1month' PERIOD_3MONTHS = '3month' PERIOD_6MONTHS = '6month' PERIOD_12MONTHS = '12month' @@ -3572,7 +3572,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS - o PERIOD_1MONTHS + o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS @@ -3593,7 +3593,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS - o PERIOD_1MONTHS + o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS @@ -3636,7 +3636,7 @@ class User(_BaseObject, _Chartable): * period: The period of time. Possible values: o PERIOD_OVERALL o PERIOD_7DAYS - o PERIOD_1MONTHS + o PERIOD_1MONTH o PERIOD_3MONTHS o PERIOD_6MONTHS o PERIOD_12MONTHS From cfa0e38ee7a42b34c830c4eee90d1468668f50a6 Mon Sep 17 00:00:00 2001 From: Joey Tuong Date: Fri, 25 Dec 2015 13:54:38 +1100 Subject: [PATCH 256/768] x.next() -> next(x) for python3 compat --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index c1879c7..a90f304 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1814,8 +1814,8 @@ class _Opus(_BaseObject, _Taggable): try: lfm = doc.getElementsByTagName('lfm')[0] - opus = self._get_children_by_tag_name(lfm, self.ws_prefix).next() - mbid = self._get_children_by_tag_name(opus, "mbid").next() + opus = next(self._get_children_by_tag_name(lfm, self.ws_prefix)) + mbid = next(self._get_children_by_tag_name(opus, "mbid")) return mbid.firstChild.nodeValue except StopIteration: return None From 950c2e78caf1bcb4fe45dbc77fe06c71c44a901d Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 Jan 2016 18:09:28 +0200 Subject: [PATCH 257/768] Skip tests which fail due to the (still) broken Last.fm API --- .gitignore | 33 +++++++++++++++------------------ tests/test_pylast.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 60b5c14..c7a6d7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +*$py.class # C extensions *.so @@ -8,11 +9,12 @@ __pycache__/ # Distribution / packaging .Python env/ -bin/ build/ develop-eggs/ dist/ +downloads/ eggs/ +.eggs/ lib/ lib64/ parts/ @@ -22,6 +24,12 @@ var/ .installed.cfg *.egg +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + # Installer logs pip-log.txt pip-delete-this-directory.txt @@ -30,33 +38,22 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .coverage +.coverage.* .cache nosetests.xml coverage.xml -output.html -output.xml +*,cover +.hypothesis/ # Translations *.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - -# Rope -.ropeproject +*.pot # Django stuff: *.log -*.pot # Sphinx documentation docs/_build/ -# Test files -test_pylast.yaml -lastfm.txt.pkl -secrets.sh - -.dir-locals.el \ No newline at end of file +# PyBuilder +target/ \ No newline at end of file diff --git a/tests/test_pylast.py b/tests/test_pylast.py index e013811..fcc0a12 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -30,6 +30,21 @@ def load_secrets(): return doc +def handle_lastfm_exceptions(f): + def wrapper(*args, **kw): + try: + return f(*args, **kw) + except pylast.WSError as e: + if (str(e) == "Invalid Method - " + "No method with that name in this package"): + msg = "Ignore broken Last.fm API: " + str(e) + print(msg) + pytest.skip(msg) + else: + raise(e) + return wrapper + + @flaky(max_runs=5, min_passes=1) class TestPyLast(unittest.TestCase): @@ -87,6 +102,7 @@ class TestPyLast(unittest.TestCase): last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) + @handle_lastfm_exceptions def test_add_album(self): # Arrange library = pylast.Library(user=self.username, network=self.network) From f17929e6cbc57513d7ab5ec16924096e5df33247 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 8 Jan 2016 11:04:02 +0200 Subject: [PATCH 258/768] Skip more tests which fail due to the (still) broken Last.fm API --- tests/test_pylast.py | 152 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index fcc0a12..6f3038c 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -31,6 +31,7 @@ def load_secrets(): def handle_lastfm_exceptions(f): + """Skip exceptions caused by Last.fm's broken API""" def wrapper(*args, **kw): try: return f(*args, **kw) @@ -67,6 +68,7 @@ class TestPyLast(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) + @handle_lastfm_exceptions def test_scrobble(self): # Arrange artist = "Test Artist" @@ -84,6 +86,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + @handle_lastfm_exceptions def test_unscrobble(self): # Arrange artist = "Test Artist 2" @@ -119,6 +122,7 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) + @handle_lastfm_exceptions def test_remove_album(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -140,6 +144,7 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) + @handle_lastfm_exceptions def test_add_artist(self): # Arrange artist = "Test Artist 2" @@ -156,6 +161,7 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) + @handle_lastfm_exceptions def test_remove_artist(self): # Arrange # Get plenty of artists @@ -176,6 +182,7 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) + @handle_lastfm_exceptions def test_get_venue(self): # Arrange venue_name = "Last.fm Office" @@ -188,6 +195,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(str(venue.id), "8778225") + @handle_lastfm_exceptions def test_get_user_registration(self): # Arrange username = "RJ" @@ -200,6 +208,7 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertIn(u"2002-11-20 ", registered) + @handle_lastfm_exceptions def test_get_user_unixtime_registration(self): # Arrange username = "RJ" @@ -212,6 +221,7 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertEqual(unixtime_registered, u"1037793040") + @handle_lastfm_exceptions def test_get_genderless_user(self): # Arrange # Currently test_user has no gender set: @@ -223,6 +233,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(gender) + @handle_lastfm_exceptions def test_get_countryless_user(self): # Arrange # Currently test_user has no country set: @@ -234,6 +245,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(country) + @handle_lastfm_exceptions def test_love(self): # Arrange artist = "Test Artist" @@ -249,6 +261,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(loved[0].track.artist), "Test Artist") self.assertEqual(str(loved[0].track.title), "Test Title") + @handle_lastfm_exceptions def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -266,6 +279,7 @@ class TestPyLast(unittest.TestCase): self.assertNotEqual(str(loved.track.artist), "Test Artist") self.assertNotEqual(str(loved.track.title), "Test Title") + @handle_lastfm_exceptions def test_get_100_albums(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -276,6 +290,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(albums), 0) + @handle_lastfm_exceptions def test_get_limitless_albums(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -286,6 +301,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(albums), 0) + @handle_lastfm_exceptions def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -296,6 +312,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertFalse(value) + @handle_lastfm_exceptions def test_user_not_equal_to_none(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -306,6 +323,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(value) + @handle_lastfm_exceptions def test_now_playing_user_with_no_scrobbles(self): # Arrange # Currently test-account has no scrobbles: @@ -317,6 +335,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(current_track) + @handle_lastfm_exceptions def test_love_limits(self): # Arrange # Currently test-account has at least 23 loved tracks: @@ -328,6 +347,7 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + @handle_lastfm_exceptions def test_update_now_playing(self): # Arrange artist = "Test Artist" @@ -346,6 +366,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.title), "Test Title") self.assertEqual(str(current_track.artist), "Test Artist") + @handle_lastfm_exceptions def test_libre_fm(self): # Arrange username = self.__class__.secrets["username"] @@ -360,6 +381,7 @@ class TestPyLast(unittest.TestCase): self.assertGreater(len(tags), 0) self.assertIsInstance(tags[0], pylast.TopItem) + @handle_lastfm_exceptions def test_album_tags_are_topitems(self): # Arrange albums = self.network.get_user('RJ').get_top_albums() @@ -382,6 +404,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(thing) self.assertEqual(len(things), 1) + @handle_lastfm_exceptions def test_album_is_hashable(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") @@ -389,6 +412,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(album) + @handle_lastfm_exceptions def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") @@ -398,6 +422,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(artist) + @handle_lastfm_exceptions def test_country_is_hashable(self): # Arrange country = self.network.get_country("Italy") @@ -405,6 +430,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(country) + @handle_lastfm_exceptions def test_metro_is_hashable(self): # Arrange metro = self.network.get_metro("Helsinki", "Finland") @@ -412,6 +438,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(metro) + @handle_lastfm_exceptions def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") @@ -420,6 +447,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(event) + @handle_lastfm_exceptions def test_group_is_hashable(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -427,6 +455,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(group) + @handle_lastfm_exceptions def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -434,6 +463,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(library) + @handle_lastfm_exceptions def test_playlist_is_hashable(self): # Arrange playlist = pylast.Playlist( @@ -442,6 +472,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(playlist) + @handle_lastfm_exceptions def test_tag_is_hashable(self): # Arrange tag = self.network.get_top_tags(limit=1)[0] @@ -449,6 +480,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(tag) + @handle_lastfm_exceptions def test_track_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -458,6 +490,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(track) + @handle_lastfm_exceptions def test_user_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -467,6 +500,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(user) + @handle_lastfm_exceptions def test_venue_is_hashable(self): # Arrange venue_id = "8778225" # Last.fm office @@ -475,6 +509,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(venue) + @handle_lastfm_exceptions def test_xspf_is_hashable(self): # Arrange xspf = pylast.XSPF( @@ -483,6 +518,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(xspf) + @handle_lastfm_exceptions def test_invalid_xml(self): # Arrange # Currently causes PCDATA invalid Char value 25 @@ -496,6 +532,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(int(total), 0) + @handle_lastfm_exceptions def test_user_play_count_in_track_info(self): # Arrange artist = "Test Artist" @@ -510,6 +547,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(count, 0) + @handle_lastfm_exceptions def test_user_loved_in_track_info(self): # Arrange artist = "Test Artist" @@ -526,6 +564,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(loved, bool) self.assertNotIsInstance(loved, str) + @handle_lastfm_exceptions def test_album_in_recent_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -537,6 +576,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(hasattr(track, 'album')) + @handle_lastfm_exceptions def test_album_in_artist_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -547,6 +587,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(hasattr(track, 'album')) + @handle_lastfm_exceptions def test_enable_rate_limiting(self): # Arrange self.assertFalse(self.network.is_rate_limited()) @@ -564,6 +605,7 @@ class TestPyLast(unittest.TestCase): self.assertTrue(self.network.is_rate_limited()) self.assertGreaterEqual(now - then, 0.2) + @handle_lastfm_exceptions def test_disable_rate_limiting(self): # Arrange self.network.enable_rate_limit() @@ -614,6 +656,7 @@ class TestPyLast(unittest.TestCase): for event in events[:2]: # checking first two should be enough self.assertIsInstance(event.get_headliner(), pylast.Artist) + @handle_lastfm_exceptions def test_artist_upcoming_events_returns_valid_ids(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -621,6 +664,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(artist) + @handle_lastfm_exceptions def test_user_past_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -628,6 +672,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_past_events_have_valid_ids(lastfm_user) + @handle_lastfm_exceptions def test_user_recommended_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -638,6 +683,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_assert_events_have_valid_ids(events) + @handle_lastfm_exceptions def test_user_upcoming_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -645,6 +691,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(lastfm_user) + @handle_lastfm_exceptions def test_venue_past_events_returns_valid_ids(self): # Arrange venue_id = "8778225" # Last.fm office @@ -653,6 +700,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_past_events_have_valid_ids(venue) + @handle_lastfm_exceptions def test_venue_upcoming_events_returns_valid_ids(self): # Arrange venue_id = "8778225" # Last.fm office @@ -661,6 +709,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(venue) + @handle_lastfm_exceptions def test_pickle(self): # Arrange import pickle @@ -677,6 +726,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(lastfm_user, loaded_user) + @handle_lastfm_exceptions def test_bio_published_date(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -688,6 +738,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) + @handle_lastfm_exceptions def test_bio_content(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -699,6 +750,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) + @handle_lastfm_exceptions def test_bio_summary(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -710,6 +762,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) + @handle_lastfm_exceptions def test_album_wiki_content(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -721,6 +774,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + @handle_lastfm_exceptions def test_album_wiki_published_date(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -732,6 +786,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + @handle_lastfm_exceptions def test_album_wiki_summary(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -743,6 +798,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + @handle_lastfm_exceptions def test_track_wiki_content(self): # Arrange track = pylast.Track("Test Artist", "Test Title", self.network) @@ -754,6 +810,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + @handle_lastfm_exceptions def test_track_wiki_summary(self): # Arrange track = pylast.Track("Test Artist", "Test Title", self.network) @@ -765,6 +822,7 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + @handle_lastfm_exceptions def test_lastfm_network_name(self): # Act name = str(self.network) @@ -796,6 +854,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) + @handle_lastfm_exceptions def test_cacheable_artist_get_shouts(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -803,6 +862,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(artist, "get_shouts") + @handle_lastfm_exceptions def test_cacheable_event_get_shouts(self): # Arrange user = self.network.get_user("RJ") @@ -811,6 +871,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(event, "get_shouts") + @handle_lastfm_exceptions def test_cacheable_track_get_shouts(self): # Arrange track = self.network.get_top_tracks()[0].item @@ -818,6 +879,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(track, "get_shouts") + @handle_lastfm_exceptions def test_cacheable_group_get_members(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -825,6 +887,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(group, "get_members") + @handle_lastfm_exceptions def test_cacheable_library(self): # Arrange library = pylast.Library(self.username, self.network) @@ -834,6 +897,7 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(library, "get_artists") self.helper_validate_cacheable(library, "get_tracks") + @handle_lastfm_exceptions def test_cacheable_user_artist_tracks(self): # Arrange lastfm_user = self.network.get_authenticated_user() @@ -846,6 +910,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) + @handle_lastfm_exceptions def test_cacheable_user(self): # Arrange lastfm_user = self.network.get_authenticated_user() @@ -860,6 +925,7 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_recommended_events") self.helper_validate_cacheable(lastfm_user, "get_shouts") + @handle_lastfm_exceptions def test_geo_get_events_in_location(self): # Arrange # Act @@ -873,6 +939,7 @@ class TestPyLast(unittest.TestCase): self.assertIn(event.get_venue().location['city'], ["London", "Camden"]) + @handle_lastfm_exceptions def test_geo_get_events_in_latlong(self): # Arrange # Act @@ -885,6 +952,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(event, pylast.Event) self.assertEqual(event.get_venue().location['city'], "Manchester") + @handle_lastfm_exceptions def test_geo_get_events_festival(self): # Arrange # Act @@ -904,6 +972,7 @@ class TestPyLast(unittest.TestCase): (start, end) = dates[0] self.assertLess(start, end) + @handle_lastfm_exceptions def test_get_metro_weekly_chart_dates(self): # Arrange # Act @@ -929,32 +998,39 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(chart[0], pylast.TopItem) self.assertIsInstance(chart[0].item, expected_type) + @handle_lastfm_exceptions def test_get_metro_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_artist_chart") + @handle_lastfm_exceptions def test_get_metro_hype_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_hype_artist_chart") + @handle_lastfm_exceptions def test_get_metro_unique_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_unique_artist_chart") + @handle_lastfm_exceptions def test_get_metro_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_track_chart", expected_type=pylast.Track) + @handle_lastfm_exceptions def test_get_metro_hype_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart( "get_hype_track_chart", expected_type=pylast.Track) + @handle_lastfm_exceptions def test_get_metro_unique_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart( "get_unique_track_chart", expected_type=pylast.Track) + @handle_lastfm_exceptions def test_geo_get_metros(self): # Arrange # Act @@ -965,6 +1041,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(metros[0], pylast.Metro) self.assertEqual(metros[0].get_country(), "Poland") + @handle_lastfm_exceptions def test_geo_get_top_artists(self): # Arrange # Act @@ -976,6 +1053,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(artists[0], pylast.TopItem) self.assertIsInstance(artists[0].item, pylast.Artist) + @handle_lastfm_exceptions def test_geo_get_top_tracks(self): # Arrange # Act @@ -987,6 +1065,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(tracks[0], pylast.TopItem) self.assertIsInstance(tracks[0].item, pylast.Track) + @handle_lastfm_exceptions def test_metro_class(self): # Arrange # Act @@ -1001,6 +1080,7 @@ class TestPyLast(unittest.TestCase): metro, pylast.Metro("Wellington", "New Zealand", self.network)) + @handle_lastfm_exceptions def test_get_album_play_links(self): # Arrange album1 = self.network.get_album("Portishead", "Dummy") @@ -1016,6 +1096,7 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:album:", links[0]) self.assertIn("spotify:album:", links[1]) + @handle_lastfm_exceptions def test_get_artist_play_links(self): # Arrange artists = ["Portishead", "Radiohead"] @@ -1028,6 +1109,7 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:artist:", links[0]) self.assertIn("spotify:artist:", links[1]) + @handle_lastfm_exceptions def test_get_track_play_links(self): # Arrange track1 = self.network.get_track(artist="Portishead", title="Mysterons") @@ -1083,6 +1165,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(thing1, expected_type) self.assertIsInstance(thing2, expected_type) + @handle_lastfm_exceptions def test_user_get_top_tags_with_limit(self): # Arrange user = self.network.get_user("RJ") @@ -1093,6 +1176,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + @handle_lastfm_exceptions def test_network_get_top_artists_with_limit(self): # Arrange # Act @@ -1101,6 +1185,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + @handle_lastfm_exceptions def test_network_get_top_tags_with_limit(self): # Arrange # Act @@ -1109,6 +1194,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + @handle_lastfm_exceptions def test_network_get_top_tags_with_no_limit(self): # Arrange # Act @@ -1117,6 +1203,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) + @handle_lastfm_exceptions def test_network_get_top_tracks_with_limit(self): # Arrange # Act @@ -1125,6 +1212,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + @handle_lastfm_exceptions def test_artist_top_tracks(self): # Arrange # Pick an artist with plenty of plays @@ -1136,6 +1224,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) + @handle_lastfm_exceptions def test_artist_top_albums(self): # Arrange # Pick an artist with plenty of plays @@ -1147,6 +1236,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) + @handle_lastfm_exceptions def test_artist_top_fans(self): # Arrange # Pick an artist with plenty of plays @@ -1158,6 +1248,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.User) + @handle_lastfm_exceptions def test_country_top_tracks(self): # Arrange country = self.network.get_country("Croatia") @@ -1168,6 +1259,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) + @handle_lastfm_exceptions def test_country_network_top_tracks(self): # Arrange # Act @@ -1176,6 +1268,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) + @handle_lastfm_exceptions def test_tag_top_tracks(self): # Arrange tag = self.network.get_tag("blues") @@ -1186,6 +1279,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) + @handle_lastfm_exceptions def test_user_top_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1219,6 +1313,7 @@ class TestPyLast(unittest.TestCase): self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) + @handle_lastfm_exceptions def test_group_charts(self): # Arrange group = self.network.get_group("mnml") @@ -1228,6 +1323,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(group, dates[-2]) + @handle_lastfm_exceptions def test_tag_charts(self): # Arrange tag = self.network.get_tag("rock") @@ -1237,6 +1333,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(tag, dates[-2]) + @handle_lastfm_exceptions def test_user_charts(self): # Arrange lastfm_user = self.network.get_user("RJ") @@ -1246,6 +1343,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[0]) + @handle_lastfm_exceptions def test_track_top_fans(self): # Arrange track = self.network.get_track("The Cinematic Orchestra", "Postlude") @@ -1275,6 +1373,7 @@ class TestPyLast(unittest.TestCase): # album/artist/event/track/user + @handle_lastfm_exceptions def test_album_shouts(self): # Arrange # Pick an artist with plenty of plays @@ -1287,6 +1386,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + @handle_lastfm_exceptions def test_artist_shouts(self): # Arrange # Pick an artist with plenty of plays @@ -1298,6 +1398,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + @handle_lastfm_exceptions def test_event_shouts(self): # Arrange event_id = 3478520 # Glasto 2014 @@ -1309,6 +1410,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + @handle_lastfm_exceptions def test_track_shouts(self): # Arrange track = self.network.get_track("The Cinematic Orchestra", "Postlude") @@ -1319,6 +1421,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + @handle_lastfm_exceptions def test_user_shouts(self): # Arrange user = self.network.get_user("RJ") @@ -1329,6 +1432,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) + @handle_lastfm_exceptions def test_album_data(self): # Arrange thing = self.network.get_album("Test Artist", "Test Album") @@ -1350,6 +1454,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual( "http://www.last.fm/music/test%2bartist/test%2balbum", url) + @handle_lastfm_exceptions def test_track_data(self): # Arrange thing = self.network.get_track("Test Artist", "Test Title") @@ -1372,6 +1477,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual( "http://www.lastfm.fr/music/test%2bartist/_/test%2btitle", url) + @handle_lastfm_exceptions def test_tag_top_artists(self): # Arrange tag = self.network.get_tag("blues") @@ -1382,6 +1488,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + @handle_lastfm_exceptions def test_country_top_artists(self): # Arrange country = self.network.get_country("Ukraine") @@ -1392,6 +1499,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + @handle_lastfm_exceptions def test_user_top_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1402,6 +1510,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + @handle_lastfm_exceptions def test_tag_top_albums(self): # Arrange tag = self.network.get_tag("blues") @@ -1412,6 +1521,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) + @handle_lastfm_exceptions def test_user_top_albums(self): # Arrange user = self.network.get_user("RJ") @@ -1422,6 +1532,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) + @handle_lastfm_exceptions def test_user_tagged_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1435,6 +1546,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) + @handle_lastfm_exceptions def test_user_tagged_albums(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1448,6 +1560,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) + @handle_lastfm_exceptions def test_user_tagged_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1460,6 +1573,7 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) + @handle_lastfm_exceptions def test_caching(self): # Arrange user = self.network.get_user("RJ") @@ -1475,6 +1589,7 @@ class TestPyLast(unittest.TestCase): self.network.disable_caching() self.assertFalse(self.network.is_caching_enabled()) + @handle_lastfm_exceptions def test_create_playlist(self): # Arrange title = "Test playlist" @@ -1490,6 +1605,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(playlist.get_description(), "Testing") self.assertEqual(playlist.get_user(), lastfm_user) + @handle_lastfm_exceptions def test_empty_playlist_unstreamable(self): # Arrange title = "Empty playlist" @@ -1503,6 +1619,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(playlist.get_duration(), 0) self.assertFalse(playlist.is_streamable()) + @handle_lastfm_exceptions def test_big_playlist_is_streamable(self): # Arrange # Find a big playlist on Last.fm, eg "top 100 classick rock songs" @@ -1523,6 +1640,7 @@ class TestPyLast(unittest.TestCase): self.assertGreater(playlist.get_duration(), 0) self.assertTrue(playlist.is_streamable()) + @handle_lastfm_exceptions def test_add_track_to_playlist(self): # Arrange title = "One track playlist" @@ -1538,6 +1656,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(playlist.get_tracks()), 1) self.assertTrue(playlist.has_track(track)) + @handle_lastfm_exceptions def test_album_mbid(self): # Arrange mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" @@ -1551,6 +1670,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(album.title.lower(), "test") self.assertEqual(album_mbid, mbid) + @handle_lastfm_exceptions def test_artist_mbid(self): # Arrange mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" @@ -1562,6 +1682,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(artist, pylast.Artist) self.assertEqual(artist.name, "MusicBrainz Test Artist") + @handle_lastfm_exceptions def test_track_mbid(self): # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" @@ -1575,6 +1696,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(track.title, "first") self.assertEqual(track_mbid, mbid) + @handle_lastfm_exceptions def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1586,6 +1708,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(count, int) self.assertGreater(count, 0) + @handle_lastfm_exceptions def test_event_attendees(self): # Arrange user = self.network.get_user("RJ") @@ -1598,6 +1721,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(users, list) self.assertIsInstance(users[0], pylast.User) + @handle_lastfm_exceptions def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1616,6 +1740,7 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(found) + @handle_lastfm_exceptions def test_remove_tag_of_type_text(self): # Arrange tag = "testing" # text @@ -1634,6 +1759,7 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) + @handle_lastfm_exceptions def test_remove_tag_of_type_tag(self): # Arrange tag = pylast.Tag("testing", self.network) # Tag @@ -1652,6 +1778,7 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) + @handle_lastfm_exceptions def test_remove_tags(self): # Arrange tags = ["removetag1", "removetag2"] @@ -1675,6 +1802,7 @@ class TestPyLast(unittest.TestCase): self.assertFalse(found1) self.assertFalse(found2) + @handle_lastfm_exceptions def test_set_tags(self): # Arrange tags = ["sometag1", "sometag2"] @@ -1699,6 +1827,7 @@ class TestPyLast(unittest.TestCase): self.assertTrue(found1) self.assertTrue(found2) + @handle_lastfm_exceptions def test_tracks_notequal(self): # Arrange track1 = pylast.Track("Test Artist", "Test Title", self.network) @@ -1708,6 +1837,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertNotEqual(track1, track2) + @handle_lastfm_exceptions def test_track_id(self): # Arrange track = pylast.Track("Test Artist", "Test Title", self.network) @@ -1718,6 +1848,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(id, "14053327") + @handle_lastfm_exceptions def test_track_title_prop_caps(self): # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -1728,6 +1859,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(title, "Test Title") + @handle_lastfm_exceptions def test_track_listener_count(self): # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -1738,6 +1870,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(count, 21) + @handle_lastfm_exceptions def test_album_rel_date(self): # Arrange album = pylast.Album("Test Artist", "Test Release", self.network) @@ -1748,6 +1881,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIn("2011", date) + @handle_lastfm_exceptions def test_album_tracks(self): # Arrange album = pylast.Album("Test Artist", "Test Release", self.network) @@ -1760,6 +1894,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(tracks[0], pylast.Track) self.assertEqual(len(tracks), 4) + @handle_lastfm_exceptions def test_tags(self): # Arrange tag1 = self.network.get_tag("blues") @@ -1787,6 +1922,7 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(found) + @handle_lastfm_exceptions def test_artists(self): # Arrange artist1 = self.network.get_artist("Radiohead") @@ -1810,6 +1946,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertIsInstance(streamable, bool) + @handle_lastfm_exceptions def test_events(self): # Arrange event_id_1 = 3162700 # Glasto 2013 @@ -1844,6 +1981,7 @@ class TestPyLast(unittest.TestCase): self.assertGreater(review_count, 0) self.assertGreater(attendance_count, 100) + @handle_lastfm_exceptions def test_countries(self): # Arrange country1 = pylast.Country("Italy", self.network) @@ -1862,6 +2000,7 @@ class TestPyLast(unittest.TestCase): self.assertTrue(country1 != country2) self.assertEqual(url, "http://www.last.fm/place/italy") + @handle_lastfm_exceptions def test_track_eq_none_is_false(self): # Arrange track1 = None @@ -1870,6 +2009,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(track1 == track2) + @handle_lastfm_exceptions def test_track_ne_none_is_true(self): # Arrange track1 = None @@ -1878,6 +2018,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(track1 != track2) + @handle_lastfm_exceptions def test_artist_eq_none_is_false(self): # Arrange artist1 = None @@ -1886,6 +2027,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(artist1 == artist2) + @handle_lastfm_exceptions def test_artist_ne_none_is_true(self): # Arrange artist1 = None @@ -1894,6 +2036,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(artist1 != artist2) + @handle_lastfm_exceptions def test_album_eq_none_is_false(self): # Arrange album1 = None @@ -1902,6 +2045,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(album1 == album2) + @handle_lastfm_exceptions def test_album_ne_none_is_true(self): # Arrange album1 = None @@ -1910,6 +2054,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(album1 != album2) + @handle_lastfm_exceptions def test_event_eq_none_is_false(self): # Arrange event1 = None @@ -1919,6 +2064,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(event1 == event2) + @handle_lastfm_exceptions def test_event_ne_none_is_true(self): # Arrange event1 = None @@ -1928,6 +2074,7 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(event1 != event2) + @handle_lastfm_exceptions def test_band_members(self): # Arrange artist = pylast.Artist("The Beatles", self.network) @@ -1938,6 +2085,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(band_members), 4) + @handle_lastfm_exceptions def test_no_band_members(self): # Arrange artist = pylast.Artist("John Lennon", self.network) @@ -1948,6 +2096,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(band_members) + @handle_lastfm_exceptions def test_get_recent_tracks_from_to(self): # Arrange lastfm_user = self.network.get_user("RJ") @@ -1968,6 +2117,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + @handle_lastfm_exceptions def test_artist_get_correction(self): # Arrange artist = pylast.Artist("guns and roses", self.network) @@ -1978,6 +2128,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") + @handle_lastfm_exceptions def test_track_get_correction(self): # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) @@ -1988,6 +2139,7 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_track_name, "Mr. Brownstone") + @handle_lastfm_exceptions def test_track_with_no_mbid(self): # Arrange track = pylast.Track("Static-X", "Set It Off", self.network) From 1732ca66861566334cabda1e72551f48fe40500f Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 9 Feb 2016 10:59:57 +0200 Subject: [PATCH 259/768] Fix build: no such option: --download-cache --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index b5dd199..a76319d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,6 @@ envlist = py34, py27, pypy, pypy3 recreate = False [testenv] -downloadcache = {homedir}/.pipcache setenv = PYLAST_USERNAME={env:PYLAST_USERNAME:} PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:} From 97c1fcc5ec071190c8f343488f5be9c1faf8d66f Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 9 Feb 2016 11:24:03 +0200 Subject: [PATCH 260/768] pep8 fixes for #153 --- pylast/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 3f310d0..ec8f54e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1136,13 +1136,15 @@ class _Request(object): else: response = self._download_response() - return minidom.parseString(_string(response).replace("opensearch:", "")) + return minidom.parseString(_string(response).replace( + "opensearch:", "")) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" try: - doc = minidom.parseString(_string(response).replace("opensearch:", "")) + doc = minidom.parseString(_string(response).replace( + "opensearch:", "")) except Exception as e: raise MalformedResponseError(self.network, e) From a60793ef1f945fd28b429964af80b7ce10bc97c2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 10 Feb 2016 11:41:32 +0200 Subject: [PATCH 261/768] Update year [CI skip] --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index ec8f54e..fbbf85d 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4,7 +4,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2015 hugovk +# Copyright 2013-2016 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ import six __version__ = '1.5.1' __author__ = 'Amr Hassan, hugovk' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2015 hugovk" +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2016 hugovk" __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' From 5eca8f556bc7fc7863bff6f56248df9dd669f59f Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 23 Apr 2016 10:22:51 +0100 Subject: [PATCH 262/768] Include tests in release source tarball A test suite is a very useful thing, it would be great to include it in the pypi release tarball. That way people who download and build the release can run the tests to ensure the library is working. Linux distributions, such as Debian, base their packages of Python modules on the pypi release. This means the test suite can be run when building a Debian package, and so catch mistakes in the packaging or errors introduced into dependencies. I've written some more on this topic on the Debian Python mailing list: https://lists.debian.org/debian-python/2016/04/msg00074.html --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index c6f00e6..c778b80 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include setup.py include README.md include COPYING include INSTALL +recursive-include tests *.py From 819572550012fc08ae5e0fd9aba451d94ffd1398 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 23 Apr 2016 14:48:29 +0300 Subject: [PATCH 263/768] Skip tests if Last.fm API still broken --- tests/test_pylast.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 6f3038c..9ba77c1 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -68,6 +68,11 @@ class TestPyLast(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) + def skip_if_lastfm_api_broken(self, value): + """Skip things not yet restored in Last.fm's broken API""" + if value is None: + pytest.skip("Last.fm API is broken.") + @handle_lastfm_exceptions def test_scrobble(self): # Arrange @@ -530,6 +535,7 @@ class TestPyLast(unittest.TestCase): total = search.get_total_result_count() # Assert + self.skip_if_lastfm_api_broken(total) self.assertGreaterEqual(int(total), 0) @handle_lastfm_exceptions @@ -1846,6 +1852,7 @@ class TestPyLast(unittest.TestCase): id = track.get_id() # Assert + self.skip_if_lastfm_api_broken(id) self.assertEqual(id, "14053327") @handle_lastfm_exceptions @@ -1879,6 +1886,7 @@ class TestPyLast(unittest.TestCase): date = album.get_release_date() # Assert + self.skip_if_lastfm_api_broken(date) self.assertIn("2011", date) @handle_lastfm_exceptions @@ -2083,6 +2091,7 @@ class TestPyLast(unittest.TestCase): band_members = artist.get_band_members() # Assert + self.skip_if_lastfm_api_broken(band_members) self.assertGreaterEqual(len(band_members), 4) @handle_lastfm_exceptions From 4c8d2e2a9b00683d52b7d50258e2b433124fb766 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 23 Apr 2016 15:27:28 +0300 Subject: [PATCH 264/768] Split test into passing and failing tests --- tests/test_pylast.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 9ba77c1..620e6ba 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -210,6 +210,10 @@ class TestPyLast(unittest.TestCase): registered = user.get_registered() # Assert + # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp + if int(registered): + pytest.skip("Last.fm API is broken.") + # Just check date because of timezones self.assertIn(u"2002-11-20 ", registered) @@ -1912,7 +1916,6 @@ class TestPyLast(unittest.TestCase): tag_repr = repr(tag1) tag_str = str(tag1) name = tag1.get_name(properly_capitalized=True) - similar = tag1.get_similar() url = tag1.get_url() # Assert @@ -1923,6 +1926,16 @@ class TestPyLast(unittest.TestCase): self.assertTrue(tag1 == tag1) self.assertTrue(tag1 != tag2) self.assertEqual(url, "http://www.last.fm/tag/blues") + + @handle_lastfm_exceptions + def test_tags_similar(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + similar = tag.get_similar() + + # Assert found = False for tag in similar: if tag.name == "delta blues": From ea3307494111c34afe1945c99ef4532802f50c2a Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 23 Apr 2016 15:28:07 +0300 Subject: [PATCH 265/768] Skip test if Last.fm API still broken --- tests/test_pylast.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 620e6ba..8e68127 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1936,6 +1936,8 @@ class TestPyLast(unittest.TestCase): similar = tag.get_similar() # Assert + if len(similar) == 0: + pytest.skip("Last.fm API is broken.") found = False for tag in similar: if tag.name == "delta blues": From 4732bf3750b205194945855f65b13a8f079cc4c9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 23 Apr 2016 15:46:49 +0300 Subject: [PATCH 266/768] Skip tests if Last.fm API still broken --- tests/test_pylast.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 8e68127..58f16bd 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -70,7 +70,7 @@ class TestPyLast(unittest.TestCase): def skip_if_lastfm_api_broken(self, value): """Skip things not yet restored in Last.fm's broken API""" - if value is None: + if value is None or len(value) == 0: pytest.skip("Last.fm API is broken.") @handle_lastfm_exceptions @@ -926,7 +926,8 @@ class TestPyLast(unittest.TestCase): lastfm_user = self.network.get_authenticated_user() # Act/Assert - self.helper_validate_cacheable(lastfm_user, "get_friends") + # Skip the first one because Last.fm API is broken + # self.helper_validate_cacheable(lastfm_user, "get_friends") self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") self.helper_validate_cacheable(lastfm_user, "get_neighbours") self.helper_validate_cacheable(lastfm_user, "get_past_events") @@ -1184,6 +1185,7 @@ class TestPyLast(unittest.TestCase): tags = user.get_top_tags(limit=1) # Assert + self.skip_if_lastfm_api_broken(tags) self.helper_only_one_thing_in_top_list(tags, pylast.Tag) @handle_lastfm_exceptions @@ -1936,8 +1938,7 @@ class TestPyLast(unittest.TestCase): similar = tag.get_similar() # Assert - if len(similar) == 0: - pytest.skip("Last.fm API is broken.") + self.skip_if_lastfm_api_broken(similar) found = False for tag in similar: if tag.name == "delta blues": From 54c4f9c093e8e51a3425ca104ac249d0303f943e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sat, 23 Apr 2016 17:28:27 +0300 Subject: [PATCH 267/768] Release 1.6.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fbbf85d..88039dd 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.5.1' +__version__ = '1.6.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2016 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 908c625..1e99522 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.5.1", + version="1.6.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], From 3d9e863cd3318851c0180885548c1c9c2c1469f2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 6 Sep 2016 10:32:05 +0300 Subject: [PATCH 268/768] Caps and things [CI skip] --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 323dec4..671e2af 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ pyLast [![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) -A Python interface to [Last.fm](http://www.last.fm/) and other api-compatible websites such as [Libre.fm](http://libre.fm/). +A Python interface to [Last.fm](http://www.last.fm/) and other API-compatible websites such as [Libre.fm](http://libre.fm/). Try using the pydoc utility for help on usage or see [test_pylast.py](tests/test_pylast.py) for examples. @@ -32,7 +32,7 @@ Features Getting Started --------------- -Here's a simple code example to get you started. In order to create any object from pyLast, you need a Network object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: +Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: ```python import pylast @@ -49,7 +49,7 @@ password_hash = pylast.md5("your_password") network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = API_SECRET, username = username, password_hash = password_hash) -# now you can use that object everywhere +# Now you can use that object everywhere artist = network.get_artist("System of a Down") artist.shout("<3") @@ -58,7 +58,7 @@ track = network.get_track("Iron Maiden", "The Nomad") track.love() track.add_tags(("awesome", "favorite")) -# type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter to get more help +# Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter to get more help # about anything and see examples of how it works ``` @@ -69,7 +69,7 @@ Testing [tests/test_pylast.py](tests/test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. Unit tests are also in the [tests/](tests/) directory. -For integration tests you need a test account at Last.fm that will be cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: +For integration tests you need a test account at Last.fm that will become cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE From 3bf6570a83082505d827b72188b66ad0ea2c07dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 7 Oct 2016 18:02:10 +0300 Subject: [PATCH 269/768] Update Libre.fm URL --- pylast/__init__.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 88039dd..7b30a52 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -915,8 +915,8 @@ class LibreFMNetwork(_Network): _Network.__init__( self, name="Libre.fm", - homepage="http://alpha.libre.fm", - ws_server=("alpha.libre.fm", "/2.0/"), + homepage="http://libre.fm", + ws_server=("libre.fm", "/2.0/"), api_key=api_key, api_secret=api_secret, session_key=session_key, @@ -924,18 +924,18 @@ class LibreFMNetwork(_Network): username=username, password_hash=password_hash, domain_names={ - DOMAIN_ENGLISH: "alpha.libre.fm", - DOMAIN_GERMAN: "alpha.libre.fm", - DOMAIN_SPANISH: "alpha.libre.fm", - DOMAIN_FRENCH: "alpha.libre.fm", - DOMAIN_ITALIAN: "alpha.libre.fm", - DOMAIN_POLISH: "alpha.libre.fm", - DOMAIN_PORTUGUESE: "alpha.libre.fm", - DOMAIN_SWEDISH: "alpha.libre.fm", - DOMAIN_TURKISH: "alpha.libre.fm", - DOMAIN_RUSSIAN: "alpha.libre.fm", - DOMAIN_JAPANESE: "alpha.libre.fm", - DOMAIN_CHINESE: "alpha.libre.fm", + DOMAIN_ENGLISH: "libre.fm", + DOMAIN_GERMAN: "libre.fm", + DOMAIN_SPANISH: "libre.fm", + DOMAIN_FRENCH: "libre.fm", + DOMAIN_ITALIAN: "libre.fm", + DOMAIN_POLISH: "libre.fm", + DOMAIN_PORTUGUESE: "libre.fm", + DOMAIN_SWEDISH: "libre.fm", + DOMAIN_TURKISH: "libre.fm", + DOMAIN_RUSSIAN: "libre.fm", + DOMAIN_JAPANESE: "libre.fm", + DOMAIN_CHINESE: "libre.fm", }, urls={ "album": "artist/%(artist)s/album/%(album)s", From 2905eaf8e26d55d2f7064d436ab0a77d4eca234d Mon Sep 17 00:00:00 2001 From: Laura Stone Date: Thu, 13 Oct 2016 10:19:13 -0400 Subject: [PATCH 270/768] Add missing dependency in README, update gitignore for JetBrains --- .gitignore | 5 ++++- README.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index c7a6d7e..292d1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ coverage.xml docs/_build/ # PyBuilder -target/ \ No newline at end of file +target/ + +# JetBrains +.idea/ diff --git a/README.md b/README.md index 323dec4..de7af0f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh -pip install pytest flaky +pip install pytest flaky mock py.test ``` From 373109c0d7a6627cfccc78056e5dc9bc4e9a942f Mon Sep 17 00:00:00 2001 From: Alejandro Angulo Date: Sun, 2 Oct 2016 15:25:22 -0700 Subject: [PATCH 271/768] don't want user creds in the repo --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 292d1ca..4535a42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# User Credentials +test_pylast.yaml + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 44c592df45c7eabb517fe3b7e748b23c0ecf64de Mon Sep 17 00:00:00 2001 From: Alejandro Angulo Date: Mon, 3 Oct 2016 20:45:28 -0700 Subject: [PATCH 272/768] switch from HTTPConnection to HTTPSConnection --- pylast/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 88039dd..0909bd9 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -43,7 +43,7 @@ def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) if sys.version_info[0] == 3: - from http.client import HTTPConnection + from http.client import HTTPSConnection import html.entities as htmlentitydefs from urllib.parse import splithost as url_split_host from urllib.parse import quote_plus as url_quote_plus @@ -51,7 +51,7 @@ if sys.version_info[0] == 3: unichr = chr elif sys.version_info[0] == 2: - from httplib import HTTPConnection + from httplib import HTTPSConnection import htmlentitydefs from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -1098,7 +1098,7 @@ class _Request(object): (HOST_NAME, HOST_SUBDIR) = self.network.ws_server if self.network.is_proxy_enabled(): - conn = HTTPConnection( + conn = HTTPSConnection( host=self.network._get_proxy()[0], port=self.network._get_proxy()[1]) @@ -1110,7 +1110,7 @@ class _Request(object): raise NetworkError(self.network, e) else: - conn = HTTPConnection(host=HOST_NAME) + conn = HTTPSConnection(host=HOST_NAME) try: conn.request( @@ -4291,7 +4291,7 @@ class _ScrobblerRequest(object): def execute(self): """Returns a string response of this request.""" - connection = HTTPConnection(self.hostname) + connection = HTTPSConnection(self.hostname) data = [] for name in self.params.keys(): From 99d567492fea17834cd66cfc066e6ebffd041a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Lundstr=C3=B6m?= Date: Wed, 5 Oct 2016 14:18:49 +0200 Subject: [PATCH 273/768] Use default SSL context when possible https://docs.python.org/2/library/ssl.html#best-defaults Deal with older Pythons which didn't do certificate validation, have sane defaults or even provided a cipher string. --- pylast/__init__.py | 107 ++++++++++++++++++++++++++++++++++++++++++--- setup.py | 6 +++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0909bd9..fb8fd41 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -42,8 +42,23 @@ __email__ = 'amr.hassan@gmail.com' def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) + +def _can_use_ssl_securely(): + # Python 3.3 doesn't support create_default_context() but can be made to + # work sanely. + # <2.7.9 and <3.2 never did any SSL verification so don't do SSL there. + # >3.4 and >2.7.9 has sane defaults so use SSL there. + v = sys.version_info + return v > (3, 3) or ((2, 7, 9) < v < (3, 0)) + +if _can_use_ssl_securely(): + import ssl + if sys.version_info[0] == 3: - from http.client import HTTPSConnection + if _can_use_ssl_securely(): + from http.client import HTTPSConnection + else: + from http.client import HTTPConnection import html.entities as htmlentitydefs from urllib.parse import splithost as url_split_host from urllib.parse import quote_plus as url_quote_plus @@ -51,7 +66,10 @@ if sys.version_info[0] == 3: unichr = chr elif sys.version_info[0] == 2: - from httplib import HTTPSConnection + if _can_use_ssl_securely(): + from httplib import HTTPSConnection + else: + from httplib import HTTPConnection import htmlentitydefs from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -131,6 +149,59 @@ RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) +# Python <=3.3 doesn't support create_default_context() +# <2.7.9 and <3.2 never did any SSL verification +# FIXME This can be removed after 2017-09 when 3.3 is no longer supported and +# pypy3 uses 3.4 or later, see +# https://en.wikipedia.org/wiki/CPython#Version_history +if sys.version_info[0] == 3 and sys.version_info[1] == 3: + import certifi + SSL_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLSv1) + SSL_CONTEXT.verify_mode = ssl.CERT_REQUIRED + SSL_CONTEXT.options |= ssl.OP_NO_COMPRESSION + # Intermediate from https://wiki.mozilla.org/Security/Server_Side_TLS + # Create the cipher string + cipher_string = """ + ECDHE-ECDSA-CHACHA20-POLY1305 + ECDHE-RSA-CHACHA20-POLY1305 + ECDHE-ECDSA-AES128-GCM-SHA256 + ECDHE-RSA-AES128-GCM-SHA256 + ECDHE-ECDSA-AES256-GCM-SHA384 + ECDHE-RSA-AES256-GCM-SHA384 + DHE-RSA-AES128-GCM-SHA256 + DHE-RSA-AES256-GCM-SHA384 + ECDHE-ECDSA-AES128-SHA256 + ECDHE-RSA-AES128-SHA256 + ECDHE-ECDSA-AES128-SHA + ECDHE-RSA-AES256-SHA384 + ECDHE-RSA-AES128-SHA + ECDHE-ECDSA-AES256-SHA384 + ECDHE-ECDSA-AES256-SHA + ECDHE-RSA-AES256-SHA + DHE-RSA-AES128-SHA256 + DHE-RSA-AES128-SHA + DHE-RSA-AES256-SHA256 + DHE-RSA-AES256-SHA + ECDHE-ECDSA-DES-CBC3-SHA + ECDHE-RSA-DES-CBC3-SHA + EDH-RSA-DES-CBC3-SHA + AES128-GCM-SHA256 + AES256-GCM-SHA384 + AES128-SHA256 + AES256-SHA256 + AES128-SHA + AES256-SHA + DES-CBC3-SHA + !DSS + """ + cipher_string = ' '.join(cipher_string.split()) + SSL_CONTEXT.set_ciphers(cipher_string) + SSL_CONTEXT.load_verify_locations(certifi.where()) + +# Python >3.4 and >2.7.9 has sane defaults +elif sys.version_info > (3, 4) or ((2, 7, 9) < sys.version_info < (3, 0)): + SSL_CONTEXT = ssl.create_default_context() + class _Network(object): """ @@ -1098,9 +1169,15 @@ class _Request(object): (HOST_NAME, HOST_SUBDIR) = self.network.ws_server if self.network.is_proxy_enabled(): - conn = HTTPSConnection( - host=self.network._get_proxy()[0], - port=self.network._get_proxy()[1]) + if _can_use_ssl_securely(): + conn = HTTPSConnection( + context=SSL_CONTEXT, + host=self.network._get_proxy()[0], + port=self.network._get_proxy()[1]) + else: + conn = HTTPConnection( + host=self.network._get_proxy()[0], + port=self.network._get_proxy()[1]) try: conn.request( @@ -1110,7 +1187,15 @@ class _Request(object): raise NetworkError(self.network, e) else: - conn = HTTPSConnection(host=HOST_NAME) + if _can_use_ssl_securely(): + conn = HTTPSConnection( + context=SSL_CONTEXT, + host=HOST_NAME + ) + else: + conn = HTTPConnection( + host=HOST_NAME + ) try: conn.request( @@ -4291,7 +4376,15 @@ class _ScrobblerRequest(object): def execute(self): """Returns a string response of this request.""" - connection = HTTPSConnection(self.hostname) + if _can_use_ssl_securely(): + connection = HTTPSConnection( + context=SSL_CONTEXT, + host=self.hostname + ) + else: + connection = HTTPConnection( + host=self.hostname + ) data = [] for name in self.params.keys(): diff --git a/setup.py b/setup.py index 1e99522..171fb43 100755 --- a/setup.py +++ b/setup.py @@ -7,6 +7,12 @@ setup( version="1.6.0", author="Amr Hassan ", install_requires=['six'], + # FIXME This can be removed after 2017-09 when 3.3 is no longer supported + # and pypy3 uses 3.4 or later, see + # https://en.wikipedia.org/wiki/CPython#Version_history + extras_require={ + ':python_version=="3.3"': ["certifi"], + }, tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], description=("A Python interface to Last.fm and Libre.fm"), author_email="amr.hassan@gmail.com", From ebee3f55684889d27f56b44cd5575da4ee45763e Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 30 Oct 2016 10:54:35 +0200 Subject: [PATCH 274/768] Error can be Libre.fm or Last.fm --- pylast/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 7b30a52..a0d5df8 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1681,15 +1681,15 @@ class WSError(Exception): class MalformedResponseError(Exception): - """Exception conveying a malformed response from Last.fm.""" + """Exception conveying a malformed response from the music network.""" def __init__(self, network, underlying_error): self.network = network self.underlying_error = underlying_error def __str__(self): - return "Malformed response from Last.fm. Underlying error: %s" % str( - self.underlying_error) + return "Malformed response from {}. Underlying error: {}".format( + self.network.name, str(self.underlying_error)) class NetworkError(Exception): From 571edf23d9966b82a0b665e2532be7dbfc3296d9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 30 Oct 2016 12:25:10 +0200 Subject: [PATCH 275/768] Separate Libre.fm test from Last.fm tests --- tests/test_pylast.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 58f16bd..3524eee 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -375,21 +375,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.title), "Test Title") self.assertEqual(str(current_track.artist), "Test Artist") - @handle_lastfm_exceptions - def test_libre_fm(self): - # Arrange - username = self.__class__.secrets["username"] - password_hash = self.__class__.secrets["password_hash"] - - # Act - network = pylast.LibreFMNetwork( - password_hash=password_hash, username=username) - tags = network.get_top_tags(limit=1) - - # Assert - self.assertGreater(len(tags), 0) - self.assertIsInstance(tags[0], pylast.TopItem) - @handle_lastfm_exceptions def test_album_tags_are_topitems(self): # Arrange @@ -2175,5 +2160,26 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(mbid, None) + +@flaky(max_runs=5, min_passes=1) +class TestPyLastWithLibreFm(unittest.TestCase): + """Own class for Libre.fm because we don't need the Last.fm setUp""" + + def test_libre_fm(self): + # Arrange + secrets = load_secrets() + username = secrets["username"] + password_hash = secrets["password_hash"] + + # Act + network = pylast.LibreFMNetwork( + password_hash=password_hash, username=username) + tags = network.get_top_tags(limit=1) + + # Assert + self.assertGreater(len(tags), 0) + self.assertIsInstance(tags[0], pylast.TopItem) + + if __name__ == '__main__': unittest.main(failfast=True) From 76d85f4c19379e1e7456b71f2ee452e5fbfb8e46 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 2 Jan 2017 15:25:21 +0200 Subject: [PATCH 276/768] Update libre.fm test --- tests/test_pylast.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 3524eee..868983d 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2174,11 +2174,11 @@ class TestPyLastWithLibreFm(unittest.TestCase): # Act network = pylast.LibreFMNetwork( password_hash=password_hash, username=username) - tags = network.get_top_tags(limit=1) + artist = network.get_artist("Radiohead") + name = artist.get_name() # Assert - self.assertGreater(len(tags), 0) - self.assertIsInstance(tags[0], pylast.TopItem) + self.assertEqual(name, "Radiohead") if __name__ == '__main__': From b410ae8b8dea9bd03519c8c260271be2f11d410e Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 2 Jan 2017 21:07:21 +0100 Subject: [PATCH 277/768] Add Python 3.5 and 3.6 (#188) --- .travis.yml | 42 +++++++++++++++++++++++++++++------------- setup.py | 2 ++ tox.ini | 2 +- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9a32908..82c9e83 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ language: python + env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= @@ -9,24 +10,39 @@ env: - secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs= - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y= - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= - matrix: - - TOXENV=lint - - TOXENV=py27 - - TOXENV=py33 - - TOXENV=py34 - - TOXENV=pypy - - TOXENV=pypy3 + +matrix: + include: + - python: 2.7 + env: TOXENV=lint + - python: 2.7 + env: TOXENV=py27 + - python: 3.6 + env: TOXENV=py36 + - python: 3.5 + env: TOXENV=py35 + - python: 3.4 + env: TOXENV=py34 + - python: 3.3 + env: TOXENV=py33 + - python: pypy3 + env: TOXENV=pypy3 + - python: pypy + env: TOXENV=pypy + allow_failures: + - env: TOXENV=pypy + - env: TOXENV=pypy3 + fast_finish: true + sudo: false + install: - travis_retry pip install tox==2.1.1 - travis_retry pip install coveralls + script: tox + after_success: - travis_retry pip install coveralls && coveralls - travis_retry pip install scrutinizer-ocular && ocular -matrix: - allow_failures: - - python: '3.4' - - python: pypy - - python: pypy3 - fast_finish: true + diff --git a/setup.py b/setup.py index 171fb43..7b45d56 100755 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ setup( "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", ], keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=('tests*',)), diff --git a/tox.ini b/tox.ini index a76319d..42f8433 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py34, py27, pypy, pypy3 +envlist = py27, py36, py35, py34, pypy, pypy3 recreate = False [testenv] From 5aa61367e0d27ee0fdb19026aac0d50715bf271a Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 3 Jan 2017 15:47:24 +0200 Subject: [PATCH 278/768] Link to develop->master PR [CI skip] --- RELEASING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index b395aaa..2268db2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Release Checklist -* [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. +* [ ] Get [master to the appropriate code release state](https://github.com/pylast/pylast/compare/master...develop?expand=1). [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. * [ ] Update version in `pylast/__init__.py` and `setup.py` and commit: ```bash git checkout master @@ -29,4 +29,3 @@ git checkout develop git merge master --ff-only git push ``` - From 6a39962d51d33e7f745a413cf305ba7d1dfd4d74 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 3 Jan 2017 16:25:50 +0200 Subject: [PATCH 279/768] Upload to PyPI with twine The old method fails: ``` Submitting dist/pylast-1.7.0.tar.gz to https://upload.pypi.org/legacy/ Upload failed (403): Invalid or non-existent authentication information. error: Upload failed (403): Invalid or non-existent authentication information. ``` Also don't need to register with twine, despite what its docs say. [CI skip] --- RELEASING.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 2268db2..ee357bf 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,23 +6,22 @@ git checkout master edit pylast/__init__.py setup.py git add pylast/__init__.py setup.py -git commit -m "Release 1.5.0" +git commit -m "Release 1.8.0" ``` * [ ] Tag the last commit with the version number: ```bash -git tag -a 1.5.0 -m "Release 1.5.0" +git tag -a 1.8.0 -m "Release 1.8.0" ``` * [ ] Release on PyPI: ```bash -python setup.py register -python setup.py sdist --format=gztar upload +twine upload dist/pylast-1.8.0.tar.gz ``` * [ ] Check installation: `pip install -U pylast` * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "1.5.0" - * Title: "Release 1.5.0" + * Tag: Pick existing tag "1.8.0" + * Title: "Release 1.8.0" * [ ] Update develop branch from master: ```bash git checkout develop From 7b3ab250df752caeba712b48be1e760bfdbc9983 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 3 Jan 2017 16:01:45 +0200 Subject: [PATCH 280/768] Release 1.7.0 --- pylast/__init__.py | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 6934ed0..1a46682 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4,7 +4,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2016 hugovk +# Copyright 2013-2017 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -32,9 +32,9 @@ import warnings import re import six -__version__ = '1.6.0' +__version__ = '1.7.0' __author__ = 'Amr Hassan, hugovk' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2016 hugovk" +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk" __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' diff --git a/setup.py b/setup.py index 7b45d56..7d7fade 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.6.0", + version="1.7.0", author="Amr Hassan ", install_requires=['six'], # FIXME This can be removed after 2017-09 when 3.3 is no longer supported From a4ec4b5b828a5756eceb845401d8bbcaad4b4167 Mon Sep 17 00:00:00 2001 From: David Metcalfe Date: Thu, 2 Feb 2017 21:27:13 -0800 Subject: [PATCH 281/768] Update README.md Updating broken Last.fm API URL to working one. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d5bb5dd..e98dcf6 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Here's some simple code example to get you started. In order to create any objec import pylast # You have to have your own unique two values for API_KEY and API_SECRET -# Obtain yours from http://www.last.fm/api/account for Last.fm +# Obtain yours from http://www.last.fm/api/account/create for Last.fm API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key API_SECRET = "425b55975eed76058ac220b7b4e8a054" From 4c520eab4a4c845660198bdcf8afe3ae000f9997 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 3 Feb 2017 09:20:02 +0200 Subject: [PATCH 282/768] Add Codecov --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 82c9e83..9bd5c36 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,11 +38,12 @@ sudo: false install: - travis_retry pip install tox==2.1.1 -- travis_retry pip install coveralls +- travis_retry pip install coverage script: tox after_success: - travis_retry pip install coveralls && coveralls +- travis_retry pip install codecov && codecov - travis_retry pip install scrutinizer-ocular && ocular From 7fa1c2005528d7cb95f88a33c055cacd7681a298 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 Feb 2017 11:05:05 +0200 Subject: [PATCH 283/768] Newlines for badges Rendered the same, but markdown is clearer. [CI skip] --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e98dcf6..04d963b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) +[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) +[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) +[![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) +[![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) +[![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) A Python interface to [Last.fm](http://www.last.fm/) and other API-compatible websites such as [Libre.fm](http://libre.fm/). From 2a46500aabccb9e0c8e9fcf9cad72c40e15fa697 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 Feb 2017 11:06:05 +0200 Subject: [PATCH 284/768] Update Coveralls badge SVG not PNG, consistent with others [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 04d963b..6462f49 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ pyLast [![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) -[![Coverage Status](https://coveralls.io/repos/pylast/pylast/badge.png?branch=develop)](https://coveralls.io/r/pylast/pylast?branch=develop) +[![Coverage Status](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) [![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) From b7479161476e824a45204dca3992bdae795f8f50 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 Feb 2017 11:08:08 +0200 Subject: [PATCH 285/768] Add Codecov badge Test drive Codecov alongside Coveralls for a bit [CI skip] --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6462f49..d5361fa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ pyLast ====== -[![Build Status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) +[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) -[![Coverage Status](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) -[![Code Health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) +[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) +[![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) +[![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) A Python interface to [Last.fm](http://www.last.fm/) and other API-compatible websites such as [Libre.fm](http://libre.fm/). From eff3f882f64608120a29a15c3e3f3e48547017ef Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 Feb 2017 11:15:06 +0200 Subject: [PATCH 286/768] pep8 example code [CI skip] --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d5361fa..d20a717 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,15 @@ import pylast # You have to have your own unique two values for API_KEY and API_SECRET # Obtain yours from http://www.last.fm/api/account/create for Last.fm -API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key +API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key API_SECRET = "425b55975eed76058ac220b7b4e8a054" # In order to perform a write operation you need to authenticate yourself username = "your_user_name" password_hash = pylast.md5("your_password") -network = pylast.LastFMNetwork(api_key = API_KEY, api_secret = - API_SECRET, username = username, password_hash = password_hash) +network = pylast.LastFMNetwork(api_key=API_KEY, api_secret=API_SECRET, + username=username, password_hash=password_hash) # Now you can use that object everywhere artist = network.get_artist("System of a Down") @@ -63,8 +63,8 @@ track = network.get_track("Iron Maiden", "The Nomad") track.love() track.add_tags(("awesome", "favorite")) -# Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter to get more help -# about anything and see examples of how it works +# Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter +# to get more help about anything and see examples of how it works ``` More examples in hugovk/lastfm-tools and [test_pylast.py](test_pylast.py). From 80f391761c9f4a8eb4ab17803a3e516c58540c1c Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Tue, 31 Jan 2017 15:00:38 -0500 Subject: [PATCH 287/768] Support authentication tokens to construct networks Closes #193 --- .gitignore | 2 ++ pylast/__init__.py | 30 ++++++++++++++++++++---------- tests/test_pylast.py | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 4535a42..51a71f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # User Credentials test_pylast.yaml +.envrc # Byte-compiled / optimized / DLL files __pycache__/ @@ -12,6 +13,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ build/ develop-eggs/ dist/ diff --git a/pylast/__init__.py b/pylast/__init__.py index 1a46682..5f96c88 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -211,7 +211,8 @@ class _Network(object): def __init__( self, name, homepage, ws_server, api_key, api_secret, session_key, - submission_server, username, password_hash, domain_names, urls): + submission_server, username, password_hash, domain_names, urls, + token=None): """ name: the name of the network homepage: the homepage URL @@ -227,6 +228,7 @@ class _Network(object): domain_names: a dict mapping each DOMAIN_* value to a string domain name urls: a dict mapping types to URLs + token: an authentication token to retrieve a session if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. @@ -257,6 +259,12 @@ class _Network(object): self.last_call_time = 0 self.limit_rate = False + # Load session_key from authentication token if provided + if token and not self.session_key: + sk_gen = SessionKeyGenerator(self) + self.session_key = sk_gen.get_web_auth_session_key( + url=None, token=token) + # Generate a session_key if necessary if ((self.api_key and self.api_secret) and not self.session_key and (self.username and self.password_hash)): @@ -886,7 +894,7 @@ class LastFMNetwork(_Network): def __init__( self, api_key="", api_secret="", session_key="", username="", - password_hash=""): + password_hash="", token=""): _Network.__init__( self, name="Last.fm", @@ -898,6 +906,7 @@ class LastFMNetwork(_Network): submission_server="http://post.audioscrobbler.com:80/", username=username, password_hash=password_hash, + token=token, domain_names={ DOMAIN_ENGLISH: 'www.last.fm', DOMAIN_GERMAN: 'www.lastfm.de', @@ -936,7 +945,7 @@ class LastFMNetwork(_Network): def get_lastfm_network( api_key="", api_secret="", session_key="", username="", - password_hash=""): + password_hash="", token=""): """ Returns a preconfigured _Network object for Last.fm @@ -946,12 +955,13 @@ def get_lastfm_network( username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password + token: an authentication token to retrieve a session if username and password_hash were provided and not session_key, session_key will be generated automatically when needed. - Either a valid session_key or a combination of username and password_hash - must be present for scrobbling. + Either a valid session_key, a combination of username and password_hash, + or token must be present for scrobbling. Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: @@ -961,7 +971,7 @@ def get_lastfm_network( _deprecation_warning("Create a LastFMNetwork object instead") return LastFMNetwork( - api_key, api_secret, session_key, username, password_hash) + api_key, api_secret, session_key, username, password_hash, token) class LibreFMNetwork(_Network): @@ -1304,7 +1314,7 @@ class SessionKeyGenerator(object): return url - def get_web_auth_session_key(self, url): + def get_web_auth_session_key(self, url, token=""): """ Retrieves the session key of a web authorization process by its url. """ @@ -1312,9 +1322,8 @@ class SessionKeyGenerator(object): if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] else: - # That's going to raise a WSError of an unauthorized token when the - # request is executed. - token = "" + # This will raise a WSError if token is blank or unauthorized + token = token request = _Request(self.network, 'auth.getSession', {'token': token}) @@ -1344,6 +1353,7 @@ class SessionKeyGenerator(object): return _extract(doc, "key") + TopItem = collections.namedtuple("TopItem", ["item", "weight"]) SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) LibraryItem = collections.namedtuple( diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 868983d..a1b6fa5 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2160,6 +2160,20 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(mbid, None) + def test_init_with_token(self): + # Arrange/Act + try: + pylast.LastFMNetwork( + api_key=self.__class__.secrets["api_key"], + api_secret=self.__class__.secrets["api_secret"], + token="invalid", + ) + except pylast.WSError as exc: + msg = str(exc) + + # Assert + self.assertEqual(msg, "Invalid authentication token supplied") + @flaky(max_runs=5, min_passes=1) class TestPyLastWithLibreFm(unittest.TestCase): From 210789a5fe6a279fcdc9961536b6fbc6ac03fca3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 13 Feb 2017 09:01:04 +0200 Subject: [PATCH 288/768] Update error message for test_init_with_token https://github.com/pylast/pylast/pull/198#issuecomment-279208373 --- tests/test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index a1b6fa5..59c71c1 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2172,7 +2172,7 @@ class TestPyLast(unittest.TestCase): msg = str(exc) # Assert - self.assertEqual(msg, "Invalid authentication token supplied") + self.assertEqual(msg, "Unauthorized Token - This token has not been issued") @flaky(max_runs=5, min_passes=1) From a9549030f41d830e15b7e594a67fb799edf7fab9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 13 Feb 2017 19:39:07 +0200 Subject: [PATCH 289/768] flake8 --- pylast/__init__.py | 1 + tests/test_pylast.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 5f96c88..ed200cf 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -51,6 +51,7 @@ def _can_use_ssl_securely(): v = sys.version_info return v > (3, 3) or ((2, 7, 9) < v < (3, 0)) + if _can_use_ssl_securely(): import ssl diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 59c71c1..fbac259 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2172,7 +2172,8 @@ class TestPyLast(unittest.TestCase): msg = str(exc) # Assert - self.assertEqual(msg, "Unauthorized Token - This token has not been issued") + self.assertEqual(msg, + "Unauthorized Token - This token has not been issued") @flaky(max_runs=5, min_passes=1) From b342bcc8c2a0aebbb70795145a1d6a0fb270c2ab Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 13 Feb 2017 20:35:18 +0200 Subject: [PATCH 290/768] Release 1.8.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index ed200cf..e533fde 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.7.0' +__version__ = '1.8.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 7d7fade..0fa18f0 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.7.0", + version="1.8.0", author="Amr Hassan ", install_requires=['six'], # FIXME This can be removed after 2017-09 when 3.3 is no longer supported From 76c9310341cc490cc7c28431ea30a669acc3b548 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 13 Feb 2017 20:42:46 +0200 Subject: [PATCH 291/768] Create a dist before upload --- RELEASING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index ee357bf..af42567 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,15 +12,16 @@ git commit -m "Release 1.8.0" ```bash git tag -a 1.8.0 -m "Release 1.8.0" ``` -* [ ] Release on PyPI: +* [ ] Create a distribution and release on PyPI: ```bash +python setup.py sdist --format=gztar twine upload dist/pylast-1.8.0.tar.gz ``` * [ ] Check installation: `pip install -U pylast` * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "1.8.0" + * Tag: Pick existing tag "1.8.0" * Title: "Release 1.8.0" * [ ] Update develop branch from master: ```bash From 2156466f709178f28de63b1da53fb3932797796a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 24 May 2017 10:51:06 +0300 Subject: [PATCH 292/768] Comment out broken download counter https://github.com/badges/shields/issues/716 [CI skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d20a717..a988b94 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) -[![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypi.python.org/pypi/pylast/) + [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) [![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) From 7584679b72c334bb8f665640fdc3c785ef24e5a3 Mon Sep 17 00:00:00 2001 From: "Isaacs, Ben" Date: Fri, 7 Jul 2017 10:33:24 +0100 Subject: [PATCH 293/768] Update links to HTTPS; update lang links --- pylast/__init__.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index e533fde..8f74a47 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -343,7 +343,7 @@ class _Network(object): """ Returns a Scrobbler object used for submitting tracks to the server - Quote from http://www.last.fm/api/submissions: + Quote from https://www.last.fm/api/submissions: ======== Client identifiers are used to provide a centrally managed database of the client versions, allowing clients to be banned if they are @@ -382,7 +382,7 @@ class _Network(object): return self.domain_names[domain_language] def _get_url(self, domain, url_type): - return "http://%s/%s" % ( + return "https://%s/%s" % ( self._get_language_domain(domain), self.urls[url_type]) def _get_ws_auth(self): @@ -396,7 +396,7 @@ class _Network(object): Makes sure that web service calls are at least 0.2 seconds apart. """ - # Delay time in seconds from section 4.4 of http://www.last.fm/api/tos + # Delay time in seconds from section 4.4 of https://www.last.fm/api/tos DELAY_TIME = 0.2 now = time.time() @@ -890,7 +890,7 @@ class LastFMNetwork(_Network): Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: - http://www.last.fm/api/account + https://www.last.fm/api/account """ def __init__( @@ -899,7 +899,7 @@ class LastFMNetwork(_Network): _Network.__init__( self, name="Last.fm", - homepage="http://last.fm", + homepage="https://www.last.fm", ws_server=("ws.audioscrobbler.com", "/2.0/"), api_key=api_key, api_secret=api_secret, @@ -910,17 +910,17 @@ class LastFMNetwork(_Network): token=token, domain_names={ DOMAIN_ENGLISH: 'www.last.fm', - DOMAIN_GERMAN: 'www.lastfm.de', - DOMAIN_SPANISH: 'www.lastfm.es', - DOMAIN_FRENCH: 'www.lastfm.fr', - DOMAIN_ITALIAN: 'www.lastfm.it', - DOMAIN_POLISH: 'www.lastfm.pl', - DOMAIN_PORTUGUESE: 'www.lastfm.com.br', - DOMAIN_SWEDISH: 'www.lastfm.se', - DOMAIN_TURKISH: 'www.lastfm.com.tr', - DOMAIN_RUSSIAN: 'www.lastfm.ru', - DOMAIN_JAPANESE: 'www.lastfm.jp', - DOMAIN_CHINESE: 'cn.last.fm', + DOMAIN_GERMAN: 'www.last.fm/de', + DOMAIN_SPANISH: 'www.last.fm/es', + DOMAIN_FRENCH: 'www.last.fm/fr', + DOMAIN_ITALIAN: 'www.last.fm/it', + DOMAIN_POLISH: 'www.last.fm/pl', + DOMAIN_PORTUGUESE: 'www.last.fm/pt', + DOMAIN_SWEDISH: 'www.last.fm/sv', + DOMAIN_TURKISH: 'www.last.fm/tr', + DOMAIN_RUSSIAN: 'www.last.fm/ru', + DOMAIN_JAPANESE: 'www.last.fm/ja', + DOMAIN_CHINESE: 'www.last.fm/zh', }, urls={ "album": "music/%(artist)s/%(album)s", @@ -966,7 +966,7 @@ def get_lastfm_network( Most read-only webservices only require an api_key and an api_secret, see about obtaining them from: - http://www.last.fm/api/account + https://www.last.fm/api/account """ _deprecation_warning("Create a LastFMNetwork object instead") @@ -997,7 +997,7 @@ class LibreFMNetwork(_Network): _Network.__init__( self, name="Libre.fm", - homepage="http://libre.fm", + homepage="https://libre.fm", ws_server=("libre.fm", "/2.0/"), api_key=api_key, api_secret=api_secret, From 5f8be53750fffb8126c78b08e3cc40af53e044f9 Mon Sep 17 00:00:00 2001 From: "Isaacs, Ben" Date: Fri, 7 Jul 2017 12:06:33 +0100 Subject: [PATCH 294/768] Update tests for https / new i18n URL scheme --- tests/test_pylast.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index fbac259..cafea6b 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1449,7 +1449,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) self.assertEqual( - "http://www.last.fm/music/test%2bartist/test%2balbum", url) + "https://www.last.fm/music/test%2bartist/test%2balbum", url) @handle_lastfm_exceptions def test_track_data(self): @@ -1472,7 +1472,7 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) self.assertEqual( - "http://www.lastfm.fr/music/test%2bartist/_/test%2btitle", url) + "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) @handle_lastfm_exceptions def test_tag_top_artists(self): @@ -1625,7 +1625,7 @@ class TestPyLast(unittest.TestCase): playlist = pylast.Playlist(user, id, self.network) self.assertEqual( playlist.get_url(), - "http://www.last.fm/user/kaxior/library/" + "https://www.last.fm/user/kaxior/library/" "playlists/67ajb_top_100_classick_rock_songs") # Act @@ -1912,7 +1912,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual("blues", name) self.assertTrue(tag1 == tag1) self.assertTrue(tag1 != tag2) - self.assertEqual(url, "http://www.last.fm/tag/blues") + self.assertEqual(url, "https://www.last.fm/tag/blues") @handle_lastfm_exceptions def test_tags_similar(self): @@ -1951,7 +1951,7 @@ class TestPyLast(unittest.TestCase): self.assertGreater(playcount, 1) self.assertTrue(artist1 != artist2) self.assertEqual(name.lower(), name_cap.lower()) - self.assertEqual(url, "http://www.last.fm/music/radiohead") + self.assertEqual(url, "https://www.last.fm/music/radiohead") self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertIsInstance(streamable, bool) @@ -2007,7 +2007,7 @@ class TestPyLast(unittest.TestCase): self.assertEqual(text, "Italy") self.assertTrue(country1 == country1) self.assertTrue(country1 != country2) - self.assertEqual(url, "http://www.last.fm/place/italy") + self.assertEqual(url, "https://www.last.fm/place/italy") @handle_lastfm_exceptions def test_track_eq_none_is_false(self): From b852ec0a80e48e0d5484562afb4ceee2f3dac713 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 7 Jul 2017 18:27:26 +0300 Subject: [PATCH 295/768] 'Test Title' is now 'test title' --- tests/test_pylast.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index cafea6b..45cbd8f 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -77,7 +77,7 @@ class TestPyLast(unittest.TestCase): def test_scrobble(self): # Arrange artist = "Test Artist" - title = "Test Title" + title = "test title" timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) @@ -258,7 +258,7 @@ class TestPyLast(unittest.TestCase): def test_love(self): # Arrange artist = "Test Artist" - title = "Test Title" + title = "test title" track = self.network.get_track(artist, title) lastfm_user = self.network.get_user(self.username) @@ -268,13 +268,13 @@ class TestPyLast(unittest.TestCase): # Assert loved = lastfm_user.get_loved_tracks(limit=1) self.assertEqual(str(loved[0].track.artist), "Test Artist") - self.assertEqual(str(loved[0].track.title), "Test Title") + self.assertEqual(str(loved[0].track.title), "test title") @handle_lastfm_exceptions def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) - title = "Test Title" + title = "test title" track = pylast.Track(artist, title, self.network) lastfm_user = self.network.get_user(self.username) track.love() @@ -286,7 +286,7 @@ class TestPyLast(unittest.TestCase): loved = lastfm_user.get_loved_tracks(limit=1) if len(loved): # OK to be empty but if not: self.assertNotEqual(str(loved.track.artist), "Test Artist") - self.assertNotEqual(str(loved.track.title), "Test Title") + self.assertNotEqual(str(loved.track.title), "test title") @handle_lastfm_exceptions def test_get_100_albums(self): @@ -360,7 +360,7 @@ class TestPyLast(unittest.TestCase): def test_update_now_playing(self): # Arrange artist = "Test Artist" - title = "Test Title" + title = "test title" album = "Test Album" track_number = 1 lastfm_user = self.network.get_user(self.username) @@ -372,7 +372,7 @@ class TestPyLast(unittest.TestCase): # Assert current_track = lastfm_user.get_now_playing() self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title), "Test Title") + self.assertEqual(str(current_track.title), "test title") self.assertEqual(str(current_track.artist), "Test Artist") @handle_lastfm_exceptions @@ -531,7 +531,7 @@ class TestPyLast(unittest.TestCase): def test_user_play_count_in_track_info(self): # Arrange artist = "Test Artist" - title = "Test Title" + title = "test title" track = pylast.Track( artist=artist, title=title, network=self.network, username=self.username) @@ -546,7 +546,7 @@ class TestPyLast(unittest.TestCase): def test_user_loved_in_track_info(self): # Arrange artist = "Test Artist" - title = "Test Title" + title = "test title" track = pylast.Track( artist=artist, title=title, network=self.network, username=self.username) @@ -796,7 +796,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_track_wiki_content(self): # Arrange - track = pylast.Track("Test Artist", "Test Title", self.network) + track = pylast.Track("Test Artist", "test title", self.network) # Act wiki = track.get_wiki_content() @@ -808,7 +808,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_track_wiki_summary(self): # Arrange - track = pylast.Track("Test Artist", "Test Title", self.network) + track = pylast.Track("Test Artist", "test title", self.network) # Act wiki = track.get_wiki_summary() @@ -1454,7 +1454,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_track_data(self): # Arrange - thing = self.network.get_track("Test Artist", "Test Title") + thing = self.network.get_track("Test Artist", "test title") # Act stringed = str(thing) @@ -1465,9 +1465,9 @@ class TestPyLast(unittest.TestCase): url = thing.get_url(pylast.DOMAIN_FRENCH) # Assert - self.assertEqual(stringed, "Test Artist - Test Title") - self.assertIn("pylast.Track('Test Artist', 'Test Title',", repr) - self.assertEqual(title, "Test Title") + self.assertEqual(stringed, "Test Artist - test title") + self.assertIn("pylast.Track('Test Artist', 'test title',", repr) + self.assertEqual(title, "test title") self.assertEqual(title, name) self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) @@ -1562,7 +1562,7 @@ class TestPyLast(unittest.TestCase): # Arrange lastfm_user = self.network.get_user(self.username) tags = ["tracktagola"] - track = self.network.get_track("Test Artist", "Test Title") + track = self.network.get_track("Test Artist", "test title") track.add_tags(tags) # Act tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) @@ -1643,7 +1643,7 @@ class TestPyLast(unittest.TestCase): title = "One track playlist" description = "Testing" playlist = self.network.create_new_playlist(title, description) - track = pylast.Track("Test Artist", "Test Title", self.network) + track = pylast.Track("Test Artist", "test title", self.network) # Act playlist.add_track(track) @@ -1827,7 +1827,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_tracks_notequal(self): # Arrange - track1 = pylast.Track("Test Artist", "Test Title", self.network) + track1 = pylast.Track("Test Artist", "test title", self.network) track2 = pylast.Track("Test Artist", "Test Track", self.network) # Act @@ -1837,7 +1837,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_track_id(self): # Arrange - track = pylast.Track("Test Artist", "Test Title", self.network) + track = pylast.Track("Test Artist", "test title", self.network) # Act id = track.get_id() @@ -1855,7 +1855,7 @@ class TestPyLast(unittest.TestCase): title = track.get_title(properly_capitalized=True) # Assert - self.assertEqual(title, "Test Title") + self.assertEqual(title, "test title") @handle_lastfm_exceptions def test_track_listener_count(self): @@ -2013,7 +2013,7 @@ class TestPyLast(unittest.TestCase): def test_track_eq_none_is_false(self): # Arrange track1 = None - track2 = pylast.Track("Test Artist", "Test Title", self.network) + track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert self.assertFalse(track1 == track2) @@ -2022,7 +2022,7 @@ class TestPyLast(unittest.TestCase): def test_track_ne_none_is_true(self): # Arrange track1 = None - track2 = pylast.Track("Test Artist", "Test Title", self.network) + track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert self.assertTrue(track1 != track2) From 9b68fb6c68085d7bb56059e6aebbfcd9de6f42be Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 21 Jul 2017 13:03:08 +0300 Subject: [PATCH 296/768] Release 1.9.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 8f74a47..79fdcfb 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import warnings import re import six -__version__ = '1.8.0' +__version__ = '1.9.0' __author__ = 'Amr Hassan, hugovk' __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk" __license__ = "apache2" diff --git a/setup.py b/setup.py index 0fa18f0..2bf413c 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="pylast", - version="1.8.0", + version="1.9.0", author="Amr Hassan ", install_requires=['six'], # FIXME This can be removed after 2017-09 when 3.3 is no longer supported From b169556e4ac85941655d2f1b190c6066d5c7ad1e Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 1 Sep 2017 20:41:59 +0100 Subject: [PATCH 297/768] correct spelling mistake --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 35835f8..c58f3f9 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2905,7 +2905,7 @@ class Tag(_BaseObject, _Chartable): return seq def get_top_albums(self, limit=None, cacheable=True): - """Retuns a list of the top albums.""" + """Returns a list of the top albums.""" params = self._get_params() if limit: params['limit'] = limit From 93bb581842982690f0fd1934ccc58b4337ca9361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mice=20P=C3=A1pai?= Date: Fri, 5 May 2017 10:46:31 +0200 Subject: [PATCH 298/768] Fix typos and style --- pylast/__init__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 79fdcfb..85fe70e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -708,7 +708,7 @@ class _Network(object): return Track(_extract(doc, "name", 1), _extract(doc, "name"), self) def get_artist_by_mbid(self, mbid): - """Loooks up an artist by its MusicBrainz ID""" + """Looks up an artist by its MusicBrainz ID""" params = {"mbid": mbid} @@ -1373,9 +1373,9 @@ Shout = collections.namedtuple( "Shout", ["body", "author", "date"]) -def _string_output(funct): +def _string_output(func): def r(*args): - return _string(funct(*args)) + return _string(func(*args)) return r @@ -1478,20 +1478,20 @@ class _BaseObject(object): """ # Last.fm currently accepts a max of 10 recipient at a time - while(len(users) > 10): + while len(users) > 10: section = users[0:9] users = users[9:] self.share(section, message) - nusers = [] + user_names = [] for user in users: if isinstance(user, User): - nusers.append(user.get_name()) + user_names.append(user.get_name()) else: - nusers.append(user) + user_names.append(user) params = self._get_params() - recipients = ','.join(nusers) + recipients = ','.join(user_names) params['recipient'] = recipients if message: params['message'] = message @@ -3004,7 +3004,7 @@ class Tag(_BaseObject, _Chartable): return seq def get_top_albums(self, limit=None, cacheable=True): - """Retuns a list of the top albums.""" + """Returns a list of the top albums.""" params = self._get_params() if limit: params['limit'] = limit @@ -3098,7 +3098,7 @@ class Track(_Opus): return _extract(doc, "streamable") == "1" def is_fulltrack_available(self): - """Returns True if the fulltrack is available for streaming.""" + """Returns True if the full track is available for streaming.""" doc = self._request(self.ws_prefix + ".getInfo", True) return doc.getElementsByTagName( @@ -4326,14 +4326,14 @@ def _unescape_htmlentity(string): return string -def extract_items(topitems_or_libraryitems): +def extract_items(top_items_or_library_items): """ Extracts a sequence of items from a sequence of TopItem or LibraryItem objects. """ seq = [] - for i in topitems_or_libraryitems: + for i in top_items_or_library_items: seq.append(i.item) return seq From 47531969b6d6f3e737492842f6968c8168d908e3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 17 Sep 2017 01:23:37 +0300 Subject: [PATCH 299/768] Drop support for Python 3.3 CPython 3.3 is no longer supported after September 29, 2017. https://en.wikipedia.org/wiki/CPython#Version_history https://www.python.org/dev/peps/pep-0398/ --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9bd5c36..fbd8e89 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,8 +23,6 @@ matrix: env: TOXENV=py35 - python: 3.4 env: TOXENV=py34 - - python: 3.3 - env: TOXENV=py33 - python: pypy3 env: TOXENV=pypy3 - python: pypy From 3a403c943f4453361f76af7ba3d6a9a28b0b3ea7 Mon Sep 17 00:00:00 2001 From: hugovk Date: Sun, 17 Sep 2017 10:49:46 +0300 Subject: [PATCH 300/768] Drop support for Python 3.3 and <=2.7.9 by removing HTTP --- README.md | 5 +- pylast/__init__.py | 122 +++++++-------------------------------------- setup.py | 9 +--- 3 files changed, 22 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index a988b94..a941f0a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ pyLast ====== -[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) -[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) +[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) +[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) @@ -20,6 +20,7 @@ Install via pip: pip install pylast +pyLast >= 2.0.0 supports Python 2.7.9+ and 3.4+. Features -------- diff --git a/pylast/__init__.py b/pylast/__init__.py index 79fdcfb..1b1337a 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -10,7 +10,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -22,15 +22,16 @@ import hashlib from xml.dom import minidom, Node -import xml.dom -import time -import shelve -import tempfile -import sys import collections -import warnings import re +import shelve import six +import ssl +import sys +import tempfile +import time +import warnings +import xml.dom __version__ = '1.9.0' __author__ = 'Amr Hassan, hugovk' @@ -43,35 +44,17 @@ def _deprecation_warning(message): warnings.warn(message, DeprecationWarning) -def _can_use_ssl_securely(): - # Python 3.3 doesn't support create_default_context() but can be made to - # work sanely. - # <2.7.9 and <3.2 never did any SSL verification so don't do SSL there. - # >3.4 and >2.7.9 has sane defaults so use SSL there. - v = sys.version_info - return v > (3, 3) or ((2, 7, 9) < v < (3, 0)) - - -if _can_use_ssl_securely(): - import ssl - if sys.version_info[0] == 3: - if _can_use_ssl_securely(): - from http.client import HTTPSConnection - else: - from http.client import HTTPConnection import html.entities as htmlentitydefs + from http.client import HTTPSConnection from urllib.parse import splithost as url_split_host from urllib.parse import quote_plus as url_quote_plus unichr = chr elif sys.version_info[0] == 2: - if _can_use_ssl_securely(): - from httplib import HTTPSConnection - else: - from httplib import HTTPConnection import htmlentitydefs + from httplib import HTTPSConnection from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus @@ -150,58 +133,8 @@ RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) -# Python <=3.3 doesn't support create_default_context() -# <2.7.9 and <3.2 never did any SSL verification -# FIXME This can be removed after 2017-09 when 3.3 is no longer supported and -# pypy3 uses 3.4 or later, see -# https://en.wikipedia.org/wiki/CPython#Version_history -if sys.version_info[0] == 3 and sys.version_info[1] == 3: - import certifi - SSL_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLSv1) - SSL_CONTEXT.verify_mode = ssl.CERT_REQUIRED - SSL_CONTEXT.options |= ssl.OP_NO_COMPRESSION - # Intermediate from https://wiki.mozilla.org/Security/Server_Side_TLS - # Create the cipher string - cipher_string = """ - ECDHE-ECDSA-CHACHA20-POLY1305 - ECDHE-RSA-CHACHA20-POLY1305 - ECDHE-ECDSA-AES128-GCM-SHA256 - ECDHE-RSA-AES128-GCM-SHA256 - ECDHE-ECDSA-AES256-GCM-SHA384 - ECDHE-RSA-AES256-GCM-SHA384 - DHE-RSA-AES128-GCM-SHA256 - DHE-RSA-AES256-GCM-SHA384 - ECDHE-ECDSA-AES128-SHA256 - ECDHE-RSA-AES128-SHA256 - ECDHE-ECDSA-AES128-SHA - ECDHE-RSA-AES256-SHA384 - ECDHE-RSA-AES128-SHA - ECDHE-ECDSA-AES256-SHA384 - ECDHE-ECDSA-AES256-SHA - ECDHE-RSA-AES256-SHA - DHE-RSA-AES128-SHA256 - DHE-RSA-AES128-SHA - DHE-RSA-AES256-SHA256 - DHE-RSA-AES256-SHA - ECDHE-ECDSA-DES-CBC3-SHA - ECDHE-RSA-DES-CBC3-SHA - EDH-RSA-DES-CBC3-SHA - AES128-GCM-SHA256 - AES256-GCM-SHA384 - AES128-SHA256 - AES256-SHA256 - AES128-SHA - AES256-SHA - DES-CBC3-SHA - !DSS - """ - cipher_string = ' '.join(cipher_string.split()) - SSL_CONTEXT.set_ciphers(cipher_string) - SSL_CONTEXT.load_verify_locations(certifi.where()) - # Python >3.4 and >2.7.9 has sane defaults -elif sys.version_info > (3, 4) or ((2, 7, 9) < sys.version_info < (3, 0)): - SSL_CONTEXT = ssl.create_default_context() +SSL_CONTEXT = ssl.create_default_context() class _Network(object): @@ -1180,15 +1113,10 @@ class _Request(object): (HOST_NAME, HOST_SUBDIR) = self.network.ws_server if self.network.is_proxy_enabled(): - if _can_use_ssl_securely(): - conn = HTTPSConnection( - context=SSL_CONTEXT, - host=self.network._get_proxy()[0], - port=self.network._get_proxy()[1]) - else: - conn = HTTPConnection( - host=self.network._get_proxy()[0], - port=self.network._get_proxy()[1]) + conn = HTTPSConnection( + context=SSL_CONTEXT, + host=self.network._get_proxy()[0], + port=self.network._get_proxy()[1]) try: conn.request( @@ -1198,15 +1126,7 @@ class _Request(object): raise NetworkError(self.network, e) else: - if _can_use_ssl_securely(): - conn = HTTPSConnection( - context=SSL_CONTEXT, - host=HOST_NAME - ) - else: - conn = HTTPConnection( - host=HOST_NAME - ) + conn = HTTPSConnection(context=SSL_CONTEXT, host=HOST_NAME) try: conn.request( @@ -4387,15 +4307,7 @@ class _ScrobblerRequest(object): def execute(self): """Returns a string response of this request.""" - if _can_use_ssl_securely(): - connection = HTTPSConnection( - context=SSL_CONTEXT, - host=self.hostname - ) - else: - connection = HTTPConnection( - host=self.hostname - ) + connection = HTTPSConnection(context=SSL_CONTEXT, host=self.hostname) data = [] for name in self.params.keys(): diff --git a/setup.py b/setup.py index 2bf413c..6d21e2a 100755 --- a/setup.py +++ b/setup.py @@ -7,12 +7,6 @@ setup( version="1.9.0", author="Amr Hassan ", install_requires=['six'], - # FIXME This can be removed after 2017-09 when 3.3 is no longer supported - # and pypy3 uses 3.4 or later, see - # https://en.wikipedia.org/wiki/CPython#Version_history - extras_require={ - ':python_version=="3.3"': ["certifi"], - }, tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], description=("A Python interface to Last.fm and Libre.fm"), author_email="amr.hassan@gmail.com", @@ -26,10 +20,11 @@ setup( "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=('tests*',)), From 41e0dfe0d5ae0dd1a43420a3b464d7bce2583c13 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 11:46:37 +0300 Subject: [PATCH 301/768] Update supported versions 2.0.0 will support 2.7.10+, not 2.7.9+. And fill in best guess for the older ones. [CI skip] --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a941f0a..6fa90c5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,13 @@ Install via pip: pip install pylast -pyLast >= 2.0.0 supports Python 2.7.9+ and 3.4+. +Note: + +* pyLast >= 2.0.0 supports Python 2.7.10+ and 3.4, 3.5, 3.6. +* pyLast >= 1.7.0 < 2.0.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. +* pyLast >= 1.0.0 < 1.7.0 supports Python 2.7, 3.3, 3.4. +* pyLast >= 0.5 < 1.0.0 supports Python 2, 3. +* pyLast < 0.5 supports Python 2. Features -------- From 4a1b50350fab85ca99f8c1780da82f677f6decfb Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 11:58:02 +0300 Subject: [PATCH 302/768] Remove deprecated code --- pylast/__init__.py | 206 +-------------------------------------------- 1 file changed, 1 insertion(+), 205 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 1b1337a..f8d9a16 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -20,17 +20,16 @@ # # https://github.com/pylast/pylast -import hashlib from xml.dom import minidom, Node import collections import re +import hashlib import shelve import six import ssl import sys import tempfile import time -import warnings import xml.dom __version__ = '1.9.0' @@ -40,10 +39,6 @@ __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' -def _deprecation_warning(message): - warnings.warn(message, DeprecationWarning) - - if sys.version_info[0] == 3: import html.entities as htmlentitydefs from http.client import HTTPSConnection @@ -272,40 +267,6 @@ class _Network(object): return Tag(name, self) - def get_scrobbler(self, client_id, client_version): - """ - Returns a Scrobbler object used for submitting tracks to the server - - Quote from https://www.last.fm/api/submissions: - ======== - Client identifiers are used to provide a centrally managed database - of the client versions, allowing clients to be banned if they are - found to be behaving undesirably. The client ID is associated with - a version number on the server, however these are only incremented - if a client is banned and do not have to reflect the version of the - actual client application. - - During development, clients which have not been allocated an - identifier should use the identifier tst, with a version number of - 1.0. Do not distribute code or client implementations which use - this test identifier. Do not use the identifiers used by other - clients. - ========= - - To obtain a new client identifier please contact: - * Last.fm: submissions@last.fm - * # TODO: list others - - ...and provide us with the name of your client and its homepage - address. - """ - - _deprecation_warning( - "Use _Network.scrobble(...), _Network.scrobble_many(...)," - " and Network.update_now_playing(...) instead") - - return Scrobbler(self, client_id, client_version) - def _get_language_domain(self, domain_language): """ Returns the mapped domain name of the network to a DOMAIN_* value @@ -877,37 +838,6 @@ class LastFMNetwork(_Network): "'%s'" % self.password_hash))) -def get_lastfm_network( - api_key="", api_secret="", session_key="", username="", - password_hash="", token=""): - """ - Returns a preconfigured _Network object for Last.fm - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the - user's password - token: an authentication token to retrieve a session - - if username and password_hash were provided and not session_key, - session_key will be generated automatically when needed. - - Either a valid session_key, a combination of username and password_hash, - or token must be present for scrobbling. - - Most read-only webservices only require an api_key and an api_secret, see - about obtaining them from: - https://www.last.fm/api/account - """ - - _deprecation_warning("Create a LastFMNetwork object instead") - - return LastFMNetwork( - api_key, api_secret, session_key, username, password_hash, token) - - class LibreFMNetwork(_Network): """ A preconfigured _Network object for Libre.fm @@ -974,30 +904,6 @@ class LibreFMNetwork(_Network): "'%s'" % self.password_hash))) -def get_librefm_network( - api_key="", api_secret="", session_key="", username="", - password_hash=""): - """ - Returns a preconfigured _Network object for Libre.fm - - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is the - user's password - - if username and password_hash were provided and not session_key, - session_key will be generated automatically when needed. - """ - - _deprecation_warning( - "DeprecationWarning: Create a LibreFMNetwork object instead") - - return LibreFMNetwork( - api_key, api_secret, session_key, username, password_hash) - - class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None): @@ -4413,114 +4319,4 @@ class Scrobbler(object): return self.session_id - def report_now_playing( - self, artist, title, album="", duration="", track_number="", - mbid=""): - - _deprecation_warning( - "DeprecationWarning: Use Network.update_now_playing(...) instead") - - params = { - "s": self._get_session_id(), "a": artist, "t": title, - "b": album, "l": duration, "n": track_number, "m": mbid} - - try: - _ScrobblerRequest( - self.nowplaying_url, params, self.network - ).execute() - except BadSessionError: - self._do_handshake() - self.report_now_playing( - artist, title, album, duration, track_number, mbid) - - def scrobble( - self, artist, title, time_started, source, mode, duration, - album="", track_number="", mbid=""): - """Scrobble a track. parameters: - artist: Artist name. - title: Track title. - time_started: UTC timestamp of when the track started playing. - source: The source of the track - SCROBBLE_SOURCE_USER: Chosen by the user - (the most common value, unless you have a reason for - choosing otherwise, use this). - SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST: Non-personalised - broadcast (e.g. Shoutcast, BBC Radio 1). - SCROBBLE_SOURCE_PERSONALIZED_BROADCAST: Personalised - recommendation except Last.fm (e.g. Pandora, Launchcast). - SCROBBLE_SOURCE_LASTFM: ast.fm (any mode). In this case, the - 5-digit recommendation_key value must be set. - SCROBBLE_SOURCE_UNKNOWN: Source unknown. - mode: The submission mode - SCROBBLE_MODE_PLAYED: The track was played. - SCROBBLE_MODE_LOVED: The user manually loved the track - (implies a listen) - SCROBBLE_MODE_SKIPPED: The track was skipped - (Only if source was Last.fm) - SCROBBLE_MODE_BANNED: The track was banned - (Only if source was Last.fm) - duration: Track duration in seconds. - album: The album name. - track_number: The track number on the album. - mbid: MusicBrainz ID. - """ - - _deprecation_warning( - "DeprecationWarning: Use Network.scrobble(...) instead") - - params = { - "s": self._get_session_id(), - "a[0]": _string(artist), - "t[0]": _string(title), - "i[0]": str(time_started), - "o[0]": source, - "r[0]": mode, - "l[0]": str(duration), - "b[0]": _string(album), - "n[0]": track_number, - "m[0]": mbid - } - - _ScrobblerRequest(self.submissions_url, params, self.network).execute() - - def scrobble_many(self, tracks): - """ - Scrobble several tracks at once. - - tracks: A sequence of a sequence of parameters for each track. - The order of parameters is the same as if passed to the - scrobble() method. - """ - - _deprecation_warning( - "DeprecationWarning: Use Network.scrobble_many(...) instead") - - remainder = [] - - if len(tracks) > 50: - remainder = tracks[50:] - tracks = tracks[:50] - - params = {"s": self._get_session_id()} - - i = 0 - for t in tracks: - _pad_list(t, 9, "") - params["a[%s]" % str(i)] = _string(t[0]) - params["t[%s]" % str(i)] = _string(t[1]) - params["i[%s]" % str(i)] = str(t[2]) - params["o[%s]" % str(i)] = t[3] - params["r[%s]" % str(i)] = t[4] - params["l[%s]" % str(i)] = str(t[5]) - params["b[%s]" % str(i)] = _string(t[6]) - params["n[%s]" % str(i)] = t[7] - params["m[%s]" % str(i)] = t[8] - - i += 1 - - _ScrobblerRequest(self.submissions_url, params, self.network).execute() - - if remainder: - self.scrobble_many(remainder) - # End of file From 5a55005240c83962b7ff38cfb70cf53bbe0d3f50 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 13:52:19 +0300 Subject: [PATCH 303/768] http -> https [CI skip] --- LICENSE.txt | 4 ++-- README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 8dada3e..9b259bd 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ Apache License Version 2.0, January 2004 - http://www.apache.org/licenses/ + https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION @@ -192,7 +192,7 @@ you may not use this file except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, diff --git a/README.md b/README.md index a941f0a..d477aa8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ pyLast [![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) -A Python interface to [Last.fm](http://www.last.fm/) and other API-compatible websites such as [Libre.fm](http://libre.fm/). +A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). Try using the pydoc utility for help on usage or see [test_pylast.py](tests/test_pylast.py) for examples. @@ -44,7 +44,7 @@ Here's some simple code example to get you started. In order to create any objec import pylast # You have to have your own unique two values for API_KEY and API_SECRET -# Obtain yours from http://www.last.fm/api/account/create for Last.fm +# Obtain yours from https://www.last.fm/api/account/create for Last.fm API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key API_SECRET = "425b55975eed76058ac220b7b4e8a054" From bdd1b8d5e5564fa15246d61770d4304e0c4c4c97 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 14:13:00 +0300 Subject: [PATCH 304/768] Add failing test --- tests/test_pylast.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 45cbd8f..2ece3c8 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1887,11 +1887,13 @@ class TestPyLast(unittest.TestCase): # Act tracks = album.get_tracks() + url = tracks[0].get_url() # Assert self.assertIsInstance(tracks, list) self.assertIsInstance(tracks[0], pylast.Track) self.assertEqual(len(tracks), 4) + self.assertTrue(url.startswith("https://www.last.fm/music/test")) @handle_lastfm_exceptions def test_tags(self): From 96d921cdc2b8303f06bdba4e7b4ac033ed18b6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mice=20P=C3=A1pai?= Date: Wed, 26 Apr 2017 01:10:16 +0200 Subject: [PATCH 305/768] Fix Album.get_tracks() error (wrong positional argument) --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index daa1efe..32a5540 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1784,7 +1784,7 @@ class Album(_Opus): return _extract_tracks( self._request( - self.ws_prefix + ".getInfo", cacheable=True), "tracks") + self.ws_prefix + ".getInfo", cacheable=True), self.network) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the album or track page on the network. From 508dc47bdb54d804d67f6f2f2c84245ccb4eb4ed Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 14:26:15 +0300 Subject: [PATCH 306/768] Cache pip files --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index fbd8e89..fe967c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: python +cache: pip + env: global: - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= From 8e99f2b04e3d5e647858cf25534fe935f175a812 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 14:47:21 +0300 Subject: [PATCH 307/768] Use different artists for test_set_tags and test_remove_tags to avoid parallel test collisions --- tests/test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 45cbd8f..cc582a3 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1803,7 +1803,7 @@ class TestPyLast(unittest.TestCase): def test_set_tags(self): # Arrange tags = ["sometag1", "sometag2"] - artist = self.network.get_artist("Test Artist") + artist = self.network.get_artist("Test Artist 2") artist.add_tags(tags) tags_before = artist.get_tags() new_tags = ["settag1", "settag2"] From 705eafbd3e3e22d670472483de5e24742f6c8711 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 18 Sep 2017 14:55:52 +0300 Subject: [PATCH 308/768] Print all warnings (-W all), and show prints (-s# disable all capturing) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 42f8433..ff9f1ed 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = ipdb pytest-cov flaky -commands = py.test -v --cov pylast --cov-report term-missing {posargs} +commands = py.test -v -s -W all --cov pylast --cov-report term-missing {posargs} [testenv:venv] deps = ipdb From 618833e297c2aa3c577d2b7050028f7b6a045483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mice=20P=C3=A1pai?= Date: Fri, 5 May 2017 10:39:04 +0200 Subject: [PATCH 309/768] Fix unclosed SSLSocket warning, Update credits --- pylast/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index daa1efe..fdd0cd9 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -33,8 +33,9 @@ import time import xml.dom __version__ = '1.9.0' -__author__ = 'Amr Hassan, hugovk' -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk" +__author__ = 'Amr Hassan, hugovk, Mice Pápai' +__copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk, ' + '2017 Mice Pápai') __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' @@ -1048,6 +1049,7 @@ class _Request(object): response_text = XML_ILLEGAL.sub("?", response_text) self._check_response_for_errors(response_text) + conn.close() return response_text def execute(self, cacheable=False): From a565b7f15925dd1dfd5d518d8325dedbeea05d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mice=20P=C3=A1pai?= Date: Wed, 26 Apr 2017 01:13:43 +0200 Subject: [PATCH 310/768] Fix: _collect_nodes() break if there are no child nodes --- pylast/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index d6ee580..2259649 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3984,6 +3984,9 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): doc = sender._request(method_name, cacheable, params) doc = cleanup_nodes(doc) + # break if there are no child nodes + if not doc.documentElement.childNodes: + break main = doc.documentElement.childNodes[0] if main.hasAttribute("totalPages"): From 4d92dcfc83a10b0fb112abfe342ebba7d13aab93 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 10:26:10 +0300 Subject: [PATCH 311/768] Remove deprecated class. Its main functions were removed in #211. --- pylast/__init__.py | 56 ---------------------------------------------- 1 file changed, 56 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index d6ee580..fd5c94e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4265,60 +4265,4 @@ class _ScrobblerRequest(object): raise ScrobblingError(reason) -class Scrobbler(object): - """A class for scrobbling tracks to Last.fm""" - - session_id = None - nowplaying_url = None - submissions_url = None - - def __init__(self, network, client_id, client_version): - self.client_id = client_id - self.client_version = client_version - self.username = network.username - self.password = network.password_hash - self.network = network - - def _do_handshake(self): - """Handshakes with the server""" - - timestamp = str(int(time.time())) - - if self.password and self.username: - token = md5(self.password + timestamp) - elif self.network.api_key and self.network.api_secret and \ - self.network.session_key: - if not self.username: - self.username = self.network.get_authenticated_user()\ - .get_name() - token = md5(self.network.api_secret + timestamp) - - params = { - "hs": "true", "p": "1.2.1", "c": self.client_id, - "v": self.client_version, "u": self.username, "t": timestamp, - "a": token} - - if self.network.session_key and self.network.api_key: - params["sk"] = self.network.session_key - params["api_key"] = self.network.api_key - - server = self.network.submission_server - response = _ScrobblerRequest( - server, params, self.network, "GET").execute().split("\n") - - self.session_id = response[1] - self.nowplaying_url = response[2] - self.submissions_url = response[3] - - def _get_session_id(self, new=False): - """ - Returns a handshake. If new is true, then it will be requested from - the server even if one was cached. - """ - - if not self.session_id or new: - self._do_handshake() - - return self.session_id - # End of file From 338abc2883df75056d7ad3ca9a87196ecefb47f5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 10:40:19 +0300 Subject: [PATCH 312/768] Lint Python 3 --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index fe967c7..fa02da5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,8 @@ matrix: env: TOXENV=lint - python: 2.7 env: TOXENV=py27 + - python: 3.6 + env: TOXENV=lint - python: 3.6 env: TOXENV=py36 - python: 3.5 From 68d9a4b78321c00f1bf7d0d6dc27a1ffb0d2dbe1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 10:55:25 +0300 Subject: [PATCH 313/768] Clonedigger only supports Python 2 --- .travis.yml | 4 ++-- tox.ini | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index fa02da5..63f0b5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,11 +16,11 @@ env: matrix: include: - python: 2.7 - env: TOXENV=lint + env: TOXENV=py2lint - python: 2.7 env: TOXENV=py27 - python: 3.6 - env: TOXENV=lint + env: TOXENV=py3lint - python: 3.6 env: TOXENV=py36 - python: 3.5 diff --git a/tox.ini b/tox.ini index ff9f1ed..76d93c5 100644 --- a/tox.ini +++ b/tox.ini @@ -21,7 +21,7 @@ commands = py.test -v -s -W all --cov pylast --cov-report term-missing {posargs} deps = ipdb commands = {posargs} -[testenv:lint] +[testenv:py2lint] deps = coverage pep8 @@ -34,3 +34,15 @@ commands = pep8 pylast pep8 tests ./clonedigger.sh + +[testenv:py3lint] +deps = + coverage + pep8 + pyyaml + pyflakes +commands = + pyflakes pylast + pyflakes tests + pep8 pylast + pep8 tests From 0ce597b5b15188d0def2eede8d2d58be85ee7e47 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 13:23:58 +0300 Subject: [PATCH 314/768] Remove unnecessary dependencies for lint --- tox.ini | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tox.ini b/tox.ini index 76d93c5..33c73be 100644 --- a/tox.ini +++ b/tox.ini @@ -23,9 +23,7 @@ commands = {posargs} [testenv:py2lint] deps = - coverage pep8 - pyyaml pyflakes clonedigger commands = @@ -37,9 +35,7 @@ commands = [testenv:py3lint] deps = - coverage pep8 - pyyaml pyflakes commands = pyflakes pylast From 32495cb15eba00c0a474a73f3d9157c98fd96209 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 13:35:32 +0300 Subject: [PATCH 315/768] Use latest Tox (revert #158) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 63f0b5a..e10837f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,7 +39,7 @@ matrix: sudo: false install: -- travis_retry pip install tox==2.1.1 +- travis_retry pip install tox - travis_retry pip install coverage script: tox From 13e965d3fd65cf1eeb129217b9c9995e724d5158 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 Sep 2017 16:47:10 +0300 Subject: [PATCH 316/768] Remove code rendered redundant after removing deprecated code --- pylast/__init__.py | 100 --------------------------------------------- 1 file changed, 100 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fd5c94e..edb14d6 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -43,7 +43,6 @@ __email__ = 'amr.hassan@gmail.com' if sys.version_info[0] == 3: import html.entities as htmlentitydefs from http.client import HTTPSConnection - from urllib.parse import splithost as url_split_host from urllib.parse import quote_plus as url_quote_plus unichr = chr @@ -51,7 +50,6 @@ if sys.version_info[0] == 3: elif sys.version_info[0] == 2: import htmlentitydefs from httplib import HTTPSConnection - from urllib import splithost as url_split_host from urllib import quote_plus as url_quote_plus STATUS_INVALID_SERVICE = 2 @@ -4167,102 +4165,4 @@ def extract_items(top_items_or_library_items): return seq -class ScrobblingError(Exception): - def __init__(self, message): - Exception.__init__(self) - self.message = message - - @_string_output - def __str__(self): - return self.message - - -class BannedClientError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__( - self, "This version of the client has been banned") - - -class BadAuthenticationError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__(self, "Bad authentication token") - - -class BadTimeError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__( - self, "Time provided is not close enough to current time") - - -class BadSessionError(ScrobblingError): - def __init__(self): - ScrobblingError.__init__( - self, "Bad session id, consider re-handshaking") - - -class _ScrobblerRequest(object): - - def __init__(self, url, params, network, request_type="POST"): - - for key in params: - params[key] = str(params[key]) - - self.params = params - self.type = request_type - (self.hostname, self.subdir) = url_split_host(url[len("http:"):]) - self.network = network - - def execute(self): - """Returns a string response of this request.""" - - connection = HTTPSConnection(context=SSL_CONTEXT, host=self.hostname) - - data = [] - for name in self.params.keys(): - value = url_quote_plus(self.params[name]) - data.append('='.join((name, value))) - data = "&".join(data) - - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept-Charset": "utf-8", - "User-Agent": "pylast" + "/" + __version__, - "HOST": self.hostname - } - - if self.type == "GET": - connection.request( - "GET", self.subdir + "?" + data, headers=headers) - else: - connection.request("POST", self.subdir, data, headers) - response = _unicode(connection.getresponse().read()) - - self._check_response_for_errors(response) - - return response - - def _check_response_for_errors(self, response): - """ - When passed a string response it checks for errors, raising any - exceptions as necessary. - """ - - lines = response.split("\n") - status_line = lines[0] - - if status_line == "OK": - return - elif status_line == "BANNED": - raise BannedClientError() - elif status_line == "BADAUTH": - raise BadAuthenticationError() - elif status_line == "BADTIME": - raise BadTimeError() - elif status_line == "BADSESSION": - raise BadSessionError() - elif status_line.startswith("FAILED "): - reason = status_line[status_line.find("FAILED ") + len("FAILED "):] - raise ScrobblingError(reason) - - # End of file From 5967590feb82fa19327b75d04a6790e0bd5bb446 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 10:40:04 +0300 Subject: [PATCH 317/768] Remove parameter made redundant by removal of deprecated Scrobbler class --- pylast/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index edb14d6..f74f3bd 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -139,8 +139,7 @@ class _Network(object): def __init__( self, name, homepage, ws_server, api_key, api_secret, session_key, - submission_server, username, password_hash, domain_names, urls, - token=None): + username, password_hash, domain_names, urls, token=None): """ name: the name of the network homepage: the homepage URL @@ -148,8 +147,6 @@ class _Network(object): api_key: a provided API_KEY api_secret: a provided API_SECRET session_key: a generated session_key or None - submission_server: the URL of the server to which tracks are - submitted (scrobbled) username: a username of a valid user password_hash: the output of pylast.md5(password) where password is the user's password @@ -175,7 +172,6 @@ class _Network(object): self.api_key = api_key self.api_secret = api_secret self.session_key = session_key - self.submission_server = submission_server self.username = username self.password_hash = password_hash self.domain_names = domain_names @@ -797,7 +793,6 @@ class LastFMNetwork(_Network): api_key=api_key, api_secret=api_secret, session_key=session_key, - submission_server="http://post.audioscrobbler.com:80/", username=username, password_hash=password_hash, token=token, @@ -864,7 +859,6 @@ class LibreFMNetwork(_Network): api_key=api_key, api_secret=api_secret, session_key=session_key, - submission_server="http://turtle.libre.fm:80/", username=username, password_hash=password_hash, domain_names={ From ebd0bb90b416bf5a20bac7cde00422613db78334 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 10:43:01 +0300 Subject: [PATCH 318/768] Remove unfinished function, it's out of scope of pylast --- pylast/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f74f3bd..f517e95 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2237,12 +2237,6 @@ class Country(_BaseObject): def _get_params(self): # TODO can move to _BaseObject return {'country': self.get_name()} - def _get_name_from_code(self, alpha2code): - # TODO: Have this function lookup the alpha-2 code and return the - # country name. - - return alpha2code - def get_name(self): """Returns the country name. """ From fb1263a8dd272ce1d91f1409adf25ad3bbf69d0c Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 10:49:52 +0300 Subject: [PATCH 319/768] Remove unused attributes --- pylast/__init__.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f517e95..36aa77e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2462,10 +2462,6 @@ class Library(_BaseObject): else: self.user = User(user, self.network) - self._albums_index = 0 - self._artists_index = 0 - self._tracks_index = 0 - def __repr__(self): return "pylast.Library(%s, %s)" % (repr(self.user), repr(self.network)) @@ -3120,10 +3116,6 @@ class User(_BaseObject, _Chartable): self.name = user_name - self._past_events_index = 0 - self._recommended_events_index = 0 - self._recommended_artists_index = 0 - def __repr__(self): return "pylast.User(%s, %s)" % (repr(self.name), repr(self.network)) From 3be6a0504e3f00694584e724d015120661921b85 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 12:24:44 +0300 Subject: [PATCH 320/768] Remove ununsed function _pad_list --- pylast/__init__.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 36aa77e..d76716e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1200,17 +1200,6 @@ def _string_output(func): return r -def _pad_list(given_list, desired_length, padding=None): - """ - Pads a list to be of the desired_length. - """ - - while len(given_list) < desired_length: - given_list.append(padding) - - return given_list - - class _BaseObject(object): """An abstract webservices object.""" From 8c6c6aaab897389ccb7f644dee5d86aac39ff5c2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 13:02:12 +0300 Subject: [PATCH 321/768] Remove broken and untested extract_items --- pylast/__init__.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index d76716e..692bec5 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -3195,9 +3195,6 @@ class User(_BaseObject, _Chartable): This method uses caching. Enable caching only if you're pulling a large amount of data. - - Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ params = self._get_params() @@ -3312,9 +3309,6 @@ class User(_BaseObject, _Chartable): This method uses caching. Enable caching only if you're pulling a large amount of data. - - Use extract_items() with the return of this function to - get only a sequence of Track objects with no playback dates. """ params = self._get_params() @@ -4121,17 +4115,4 @@ def _unescape_htmlentity(string): return string -def extract_items(top_items_or_library_items): - """ - Extracts a sequence of items from a sequence of TopItem or - LibraryItem objects. - """ - - seq = [] - for i in top_items_or_library_items: - seq.append(i.item) - - return seq - - # End of file From fe6673ba29f643e96183cd52644be494edddc22c Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 13:14:17 +0300 Subject: [PATCH 322/768] Add more tests --- tests/test_pylast.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index d6fe823..ab8ccb4 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1564,12 +1564,49 @@ class TestPyLast(unittest.TestCase): tags = ["tracktagola"] track = self.network.get_track("Test Artist", "test title") track.add_tags(tags) + # Act tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) + @handle_lastfm_exceptions + def test_user_subscriber(self): + # Arrange + subscriber = self.network.get_user("RJ") + non_subscriber = self.network.get_user("Test User") + + # Act + subscriber_is_subscriber = subscriber.is_subscriber() + non_subscriber_is_subscriber = non_subscriber.is_subscriber() + + # Assert + self.assertTrue(subscriber_is_subscriber) + self.assertFalse(non_subscriber_is_subscriber) + + @handle_lastfm_exceptions + def test_user_get_image(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + url = user.get_image() + + # Assert + self.assertTrue(url.startswith("https://")) + + @handle_lastfm_exceptions + def test_user_get_library(self): + # Arrange + user = self.network.get_user(self.username) + + # Act + library = user.get_library() + + # Assert + self.assertIsInstance(library, pylast.Library) + @handle_lastfm_exceptions def test_caching(self): # Arrange From e19061a437a472e29bc7c82aba3093c7167f0cff Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 14:29:44 +0300 Subject: [PATCH 323/768] Re-enable test for re-added Last.fm API --- tests/test_pylast.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index d6fe823..f636061 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -911,8 +911,7 @@ class TestPyLast(unittest.TestCase): lastfm_user = self.network.get_authenticated_user() # Act/Assert - # Skip the first one because Last.fm API is broken - # self.helper_validate_cacheable(lastfm_user, "get_friends") + self.helper_validate_cacheable(lastfm_user, "get_friends") self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") self.helper_validate_cacheable(lastfm_user, "get_neighbours") self.helper_validate_cacheable(lastfm_user, "get_past_events") From 0d4f674ac7071230b1f14cf5f45dc21cad720993 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 17:28:02 +0300 Subject: [PATCH 324/768] Remove dead Last.fm methods: get_top_fans --- pylast/__init__.py | 24 ------------------------ tests/test_pylast.py | 27 +-------------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fd17e99..245e1a3 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1252,30 +1252,6 @@ class _BaseObject(object): return seq - def get_top_fans(self, limit=None, cacheable=True): - """Returns a list of the Users who played this the most. - # Parameters: - * limit int: Max elements. - # For Artist/Track - """ - - doc = self._request(self.ws_prefix + '.getTopFans', cacheable) - - seq = [] - - elements = doc.getElementsByTagName('user') - - for element in elements: - if limit and len(seq) >= limit: - break - - name = _extract(element, 'name') - weight = _number(_extract(element, 'weight')) - - seq.append(TopItem(User(name, self.network), weight)) - - return seq - def share(self, users, message=None): """ Shares this (sends out recommendations). diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 0433fb9..a2c27a6 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -487,9 +487,7 @@ class TestPyLast(unittest.TestCase): @handle_lastfm_exceptions def test_user_is_hashable(self): # Arrange - artist = self.network.get_artist("Test Artist") - user = artist.get_top_fans(limit=1)[0].item - self.assertIsInstance(user, pylast.User) + user = self.network.get_user(self.username) # Act/Assert self.helper_is_thing_hashable(user) @@ -1232,18 +1230,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) - @handle_lastfm_exceptions - def test_artist_top_fans(self): - # Arrange - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - things = artist.get_top_fans(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.User) - @handle_lastfm_exceptions def test_country_top_tracks(self): # Arrange @@ -1339,17 +1325,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[0]) - @handle_lastfm_exceptions - def test_track_top_fans(self): - # Arrange - track = self.network.get_track("The Cinematic Orchestra", "Postlude") - - # Act - fans = track.get_top_fans() - - # Assert - self.helper_at_least_one_thing_in_top_list(fans, pylast.User) - # Commented out to avoid spamming # def test_share_spam(self): # # Arrange From c303fd0139e000f10f4cdec0d3795cd066b39fd6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 17:37:24 +0300 Subject: [PATCH 325/768] Remove dead Last.fm attributes: releasedate in album.getInfo --- pylast/__init__.py | 6 ------ tests/test_pylast.py | 12 ------------ 2 files changed, 18 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 245e1a3..da167c6 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1718,12 +1718,6 @@ class Album(_Opus): def __init__(self, artist, title, network, username=None): super(Album, self).__init__(artist, title, network, "album", username) - def get_release_date(self): - """Returns the release date of the album.""" - - return _extract(self._request( - self.ws_prefix + ".getInfo", cacheable=True), "releasedate") - def get_cover_image(self, size=COVER_EXTRA_LARGE): """ Returns a uri to the cover image diff --git a/tests/test_pylast.py b/tests/test_pylast.py index a2c27a6..efeb960 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -1879,18 +1879,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(count, 21) - @handle_lastfm_exceptions - def test_album_rel_date(self): - # Arrange - album = pylast.Album("Test Artist", "Test Release", self.network) - - # Act - date = album.get_release_date() - - # Assert - self.skip_if_lastfm_api_broken(date) - self.assertIn("2011", date) - @handle_lastfm_exceptions def test_album_tracks(self): # Arrange From 3e097b98fc42912075c2e44f16c368e46eb1205c Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 17:49:12 +0300 Subject: [PATCH 326/768] Remove @handle_lastfm_exceptions: dead Last.fm things will be removed --- tests/test_pylast.py | 168 ------------------------------------------- 1 file changed, 168 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index efeb960..6a7799e 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -30,22 +30,6 @@ def load_secrets(): return doc -def handle_lastfm_exceptions(f): - """Skip exceptions caused by Last.fm's broken API""" - def wrapper(*args, **kw): - try: - return f(*args, **kw) - except pylast.WSError as e: - if (str(e) == "Invalid Method - " - "No method with that name in this package"): - msg = "Ignore broken Last.fm API: " + str(e) - print(msg) - pytest.skip(msg) - else: - raise(e) - return wrapper - - @flaky(max_runs=5, min_passes=1) class TestPyLast(unittest.TestCase): @@ -73,7 +57,6 @@ class TestPyLast(unittest.TestCase): if value is None or len(value) == 0: pytest.skip("Last.fm API is broken.") - @handle_lastfm_exceptions def test_scrobble(self): # Arrange artist = "Test Artist" @@ -91,7 +74,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - @handle_lastfm_exceptions def test_unscrobble(self): # Arrange artist = "Test Artist 2" @@ -110,7 +92,6 @@ class TestPyLast(unittest.TestCase): last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) - @handle_lastfm_exceptions def test_add_album(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -127,7 +108,6 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) - @handle_lastfm_exceptions def test_remove_album(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -149,7 +129,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) - @handle_lastfm_exceptions def test_add_artist(self): # Arrange artist = "Test Artist 2" @@ -166,7 +145,6 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(value) - @handle_lastfm_exceptions def test_remove_artist(self): # Arrange # Get plenty of artists @@ -187,7 +165,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(value) - @handle_lastfm_exceptions def test_get_venue(self): # Arrange venue_name = "Last.fm Office" @@ -200,7 +177,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(str(venue.id), "8778225") - @handle_lastfm_exceptions def test_get_user_registration(self): # Arrange username = "RJ" @@ -217,7 +193,6 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertIn(u"2002-11-20 ", registered) - @handle_lastfm_exceptions def test_get_user_unixtime_registration(self): # Arrange username = "RJ" @@ -230,7 +205,6 @@ class TestPyLast(unittest.TestCase): # Just check date because of timezones self.assertEqual(unixtime_registered, u"1037793040") - @handle_lastfm_exceptions def test_get_genderless_user(self): # Arrange # Currently test_user has no gender set: @@ -242,7 +216,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(gender) - @handle_lastfm_exceptions def test_get_countryless_user(self): # Arrange # Currently test_user has no country set: @@ -254,7 +227,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(country) - @handle_lastfm_exceptions def test_love(self): # Arrange artist = "Test Artist" @@ -270,7 +242,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(loved[0].track.artist), "Test Artist") self.assertEqual(str(loved[0].track.title), "test title") - @handle_lastfm_exceptions def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -288,7 +259,6 @@ class TestPyLast(unittest.TestCase): self.assertNotEqual(str(loved.track.artist), "Test Artist") self.assertNotEqual(str(loved.track.title), "test title") - @handle_lastfm_exceptions def test_get_100_albums(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -299,7 +269,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(albums), 0) - @handle_lastfm_exceptions def test_get_limitless_albums(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -310,7 +279,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(len(albums), 0) - @handle_lastfm_exceptions def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -321,7 +289,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertFalse(value) - @handle_lastfm_exceptions def test_user_not_equal_to_none(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -332,7 +299,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(value) - @handle_lastfm_exceptions def test_now_playing_user_with_no_scrobbles(self): # Arrange # Currently test-account has no scrobbles: @@ -344,7 +310,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(current_track) - @handle_lastfm_exceptions def test_love_limits(self): # Arrange # Currently test-account has at least 23 loved tracks: @@ -356,7 +321,6 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) - @handle_lastfm_exceptions def test_update_now_playing(self): # Arrange artist = "Test Artist" @@ -375,7 +339,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(current_track.title), "test title") self.assertEqual(str(current_track.artist), "Test Artist") - @handle_lastfm_exceptions def test_album_tags_are_topitems(self): # Arrange albums = self.network.get_user('RJ').get_top_albums() @@ -398,7 +361,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(thing) self.assertEqual(len(things), 1) - @handle_lastfm_exceptions def test_album_is_hashable(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") @@ -406,7 +368,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(album) - @handle_lastfm_exceptions def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") @@ -416,7 +377,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(artist) - @handle_lastfm_exceptions def test_country_is_hashable(self): # Arrange country = self.network.get_country("Italy") @@ -424,7 +384,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(country) - @handle_lastfm_exceptions def test_metro_is_hashable(self): # Arrange metro = self.network.get_metro("Helsinki", "Finland") @@ -432,7 +391,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(metro) - @handle_lastfm_exceptions def test_event_is_hashable(self): # Arrange user = self.network.get_user("RJ") @@ -441,7 +399,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(event) - @handle_lastfm_exceptions def test_group_is_hashable(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -449,7 +406,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(group) - @handle_lastfm_exceptions def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -457,7 +413,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(library) - @handle_lastfm_exceptions def test_playlist_is_hashable(self): # Arrange playlist = pylast.Playlist( @@ -466,7 +421,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(playlist) - @handle_lastfm_exceptions def test_tag_is_hashable(self): # Arrange tag = self.network.get_top_tags(limit=1)[0] @@ -474,7 +428,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(tag) - @handle_lastfm_exceptions def test_track_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -484,7 +437,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(track) - @handle_lastfm_exceptions def test_user_is_hashable(self): # Arrange user = self.network.get_user(self.username) @@ -492,7 +444,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(user) - @handle_lastfm_exceptions def test_venue_is_hashable(self): # Arrange venue_id = "8778225" # Last.fm office @@ -501,7 +452,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(venue) - @handle_lastfm_exceptions def test_xspf_is_hashable(self): # Arrange xspf = pylast.XSPF( @@ -510,7 +460,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(xspf) - @handle_lastfm_exceptions def test_invalid_xml(self): # Arrange # Currently causes PCDATA invalid Char value 25 @@ -525,7 +474,6 @@ class TestPyLast(unittest.TestCase): self.skip_if_lastfm_api_broken(total) self.assertGreaterEqual(int(total), 0) - @handle_lastfm_exceptions def test_user_play_count_in_track_info(self): # Arrange artist = "Test Artist" @@ -540,7 +488,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreaterEqual(count, 0) - @handle_lastfm_exceptions def test_user_loved_in_track_info(self): # Arrange artist = "Test Artist" @@ -557,7 +504,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(loved, bool) self.assertNotIsInstance(loved, str) - @handle_lastfm_exceptions def test_album_in_recent_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -569,7 +515,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(hasattr(track, 'album')) - @handle_lastfm_exceptions def test_album_in_artist_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -580,7 +525,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(hasattr(track, 'album')) - @handle_lastfm_exceptions def test_enable_rate_limiting(self): # Arrange self.assertFalse(self.network.is_rate_limited()) @@ -598,7 +542,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(self.network.is_rate_limited()) self.assertGreaterEqual(now - then, 0.2) - @handle_lastfm_exceptions def test_disable_rate_limiting(self): # Arrange self.network.enable_rate_limit() @@ -649,7 +592,6 @@ class TestPyLast(unittest.TestCase): for event in events[:2]: # checking first two should be enough self.assertIsInstance(event.get_headliner(), pylast.Artist) - @handle_lastfm_exceptions def test_artist_upcoming_events_returns_valid_ids(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -657,7 +599,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(artist) - @handle_lastfm_exceptions def test_user_past_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -665,7 +606,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_past_events_have_valid_ids(lastfm_user) - @handle_lastfm_exceptions def test_user_recommended_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -676,7 +616,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_assert_events_have_valid_ids(events) - @handle_lastfm_exceptions def test_user_upcoming_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -684,7 +623,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(lastfm_user) - @handle_lastfm_exceptions def test_venue_past_events_returns_valid_ids(self): # Arrange venue_id = "8778225" # Last.fm office @@ -693,7 +631,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_past_events_have_valid_ids(venue) - @handle_lastfm_exceptions def test_venue_upcoming_events_returns_valid_ids(self): # Arrange venue_id = "8778225" # Last.fm office @@ -702,7 +639,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_upcoming_events_have_valid_ids(venue) - @handle_lastfm_exceptions def test_pickle(self): # Arrange import pickle @@ -719,7 +655,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(lastfm_user, loaded_user) - @handle_lastfm_exceptions def test_bio_published_date(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -731,7 +666,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) - @handle_lastfm_exceptions def test_bio_content(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -743,7 +677,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) - @handle_lastfm_exceptions def test_bio_summary(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -755,7 +688,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) - @handle_lastfm_exceptions def test_album_wiki_content(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -767,7 +699,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - @handle_lastfm_exceptions def test_album_wiki_published_date(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -779,7 +710,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - @handle_lastfm_exceptions def test_album_wiki_summary(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -791,7 +721,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - @handle_lastfm_exceptions def test_track_wiki_content(self): # Arrange track = pylast.Track("Test Artist", "test title", self.network) @@ -803,7 +732,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - @handle_lastfm_exceptions def test_track_wiki_summary(self): # Arrange track = pylast.Track("Test Artist", "test title", self.network) @@ -815,7 +743,6 @@ class TestPyLast(unittest.TestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - @handle_lastfm_exceptions def test_lastfm_network_name(self): # Act name = str(self.network) @@ -847,7 +774,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) - @handle_lastfm_exceptions def test_cacheable_artist_get_shouts(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -855,7 +781,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(artist, "get_shouts") - @handle_lastfm_exceptions def test_cacheable_event_get_shouts(self): # Arrange user = self.network.get_user("RJ") @@ -864,7 +789,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(event, "get_shouts") - @handle_lastfm_exceptions def test_cacheable_track_get_shouts(self): # Arrange track = self.network.get_top_tracks()[0].item @@ -872,7 +796,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(track, "get_shouts") - @handle_lastfm_exceptions def test_cacheable_group_get_members(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -880,7 +803,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(group, "get_members") - @handle_lastfm_exceptions def test_cacheable_library(self): # Arrange library = pylast.Library(self.username, self.network) @@ -890,7 +812,6 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(library, "get_artists") self.helper_validate_cacheable(library, "get_tracks") - @handle_lastfm_exceptions def test_cacheable_user_artist_tracks(self): # Arrange lastfm_user = self.network.get_authenticated_user() @@ -903,7 +824,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) - @handle_lastfm_exceptions def test_cacheable_user(self): # Arrange lastfm_user = self.network.get_authenticated_user() @@ -918,7 +838,6 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_recommended_events") self.helper_validate_cacheable(lastfm_user, "get_shouts") - @handle_lastfm_exceptions def test_geo_get_events_in_location(self): # Arrange # Act @@ -932,7 +851,6 @@ class TestPyLast(unittest.TestCase): self.assertIn(event.get_venue().location['city'], ["London", "Camden"]) - @handle_lastfm_exceptions def test_geo_get_events_in_latlong(self): # Arrange # Act @@ -945,7 +863,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(event, pylast.Event) self.assertEqual(event.get_venue().location['city'], "Manchester") - @handle_lastfm_exceptions def test_geo_get_events_festival(self): # Arrange # Act @@ -965,7 +882,6 @@ class TestPyLast(unittest.TestCase): (start, end) = dates[0] self.assertLess(start, end) - @handle_lastfm_exceptions def test_get_metro_weekly_chart_dates(self): # Arrange # Act @@ -991,39 +907,32 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(chart[0], pylast.TopItem) self.assertIsInstance(chart[0].item, expected_type) - @handle_lastfm_exceptions def test_get_metro_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_artist_chart") - @handle_lastfm_exceptions def test_get_metro_hype_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_hype_artist_chart") - @handle_lastfm_exceptions def test_get_metro_unique_artist_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_unique_artist_chart") - @handle_lastfm_exceptions def test_get_metro_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart("get_track_chart", expected_type=pylast.Track) - @handle_lastfm_exceptions def test_get_metro_hype_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart( "get_hype_track_chart", expected_type=pylast.Track) - @handle_lastfm_exceptions def test_get_metro_unique_track_chart(self): # Arrange/Act/Assert self.helper_geo_chart( "get_unique_track_chart", expected_type=pylast.Track) - @handle_lastfm_exceptions def test_geo_get_metros(self): # Arrange # Act @@ -1034,7 +943,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(metros[0], pylast.Metro) self.assertEqual(metros[0].get_country(), "Poland") - @handle_lastfm_exceptions def test_geo_get_top_artists(self): # Arrange # Act @@ -1046,7 +954,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(artists[0], pylast.TopItem) self.assertIsInstance(artists[0].item, pylast.Artist) - @handle_lastfm_exceptions def test_geo_get_top_tracks(self): # Arrange # Act @@ -1058,7 +965,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(tracks[0], pylast.TopItem) self.assertIsInstance(tracks[0].item, pylast.Track) - @handle_lastfm_exceptions def test_metro_class(self): # Arrange # Act @@ -1073,7 +979,6 @@ class TestPyLast(unittest.TestCase): metro, pylast.Metro("Wellington", "New Zealand", self.network)) - @handle_lastfm_exceptions def test_get_album_play_links(self): # Arrange album1 = self.network.get_album("Portishead", "Dummy") @@ -1089,7 +994,6 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:album:", links[0]) self.assertIn("spotify:album:", links[1]) - @handle_lastfm_exceptions def test_get_artist_play_links(self): # Arrange artists = ["Portishead", "Radiohead"] @@ -1102,7 +1006,6 @@ class TestPyLast(unittest.TestCase): self.assertIn("spotify:artist:", links[0]) self.assertIn("spotify:artist:", links[1]) - @handle_lastfm_exceptions def test_get_track_play_links(self): # Arrange track1 = self.network.get_track(artist="Portishead", title="Mysterons") @@ -1158,7 +1061,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(thing1, expected_type) self.assertIsInstance(thing2, expected_type) - @handle_lastfm_exceptions def test_user_get_top_tags_with_limit(self): # Arrange user = self.network.get_user("RJ") @@ -1170,7 +1072,6 @@ class TestPyLast(unittest.TestCase): self.skip_if_lastfm_api_broken(tags) self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - @handle_lastfm_exceptions def test_network_get_top_artists_with_limit(self): # Arrange # Act @@ -1179,7 +1080,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - @handle_lastfm_exceptions def test_network_get_top_tags_with_limit(self): # Arrange # Act @@ -1188,7 +1088,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - @handle_lastfm_exceptions def test_network_get_top_tags_with_no_limit(self): # Arrange # Act @@ -1197,7 +1096,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) - @handle_lastfm_exceptions def test_network_get_top_tracks_with_limit(self): # Arrange # Act @@ -1206,7 +1104,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) - @handle_lastfm_exceptions def test_artist_top_tracks(self): # Arrange # Pick an artist with plenty of plays @@ -1218,7 +1115,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - @handle_lastfm_exceptions def test_artist_top_albums(self): # Arrange # Pick an artist with plenty of plays @@ -1230,7 +1126,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) - @handle_lastfm_exceptions def test_country_top_tracks(self): # Arrange country = self.network.get_country("Croatia") @@ -1241,7 +1136,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - @handle_lastfm_exceptions def test_country_network_top_tracks(self): # Arrange # Act @@ -1250,7 +1144,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - @handle_lastfm_exceptions def test_tag_top_tracks(self): # Arrange tag = self.network.get_tag("blues") @@ -1261,7 +1154,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - @handle_lastfm_exceptions def test_user_top_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1295,7 +1187,6 @@ class TestPyLast(unittest.TestCase): self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) - @handle_lastfm_exceptions def test_group_charts(self): # Arrange group = self.network.get_group("mnml") @@ -1305,7 +1196,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(group, dates[-2]) - @handle_lastfm_exceptions def test_tag_charts(self): # Arrange tag = self.network.get_tag("rock") @@ -1315,7 +1205,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_get_assert_charts(tag, dates[-2]) - @handle_lastfm_exceptions def test_user_charts(self): # Arrange lastfm_user = self.network.get_user("RJ") @@ -1344,7 +1233,6 @@ class TestPyLast(unittest.TestCase): # album/artist/event/track/user - @handle_lastfm_exceptions def test_album_shouts(self): # Arrange # Pick an artist with plenty of plays @@ -1357,7 +1245,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) - @handle_lastfm_exceptions def test_artist_shouts(self): # Arrange # Pick an artist with plenty of plays @@ -1369,7 +1256,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) - @handle_lastfm_exceptions def test_event_shouts(self): # Arrange event_id = 3478520 # Glasto 2014 @@ -1381,7 +1267,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) - @handle_lastfm_exceptions def test_track_shouts(self): # Arrange track = self.network.get_track("The Cinematic Orchestra", "Postlude") @@ -1392,7 +1277,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) - @handle_lastfm_exceptions def test_user_shouts(self): # Arrange user = self.network.get_user("RJ") @@ -1403,7 +1287,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_two_things_in_list(shouts, pylast.Shout) - @handle_lastfm_exceptions def test_album_data(self): # Arrange thing = self.network.get_album("Test Artist", "Test Album") @@ -1425,7 +1308,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual( "https://www.last.fm/music/test%2bartist/test%2balbum", url) - @handle_lastfm_exceptions def test_track_data(self): # Arrange thing = self.network.get_track("Test Artist", "test title") @@ -1448,7 +1330,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual( "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) - @handle_lastfm_exceptions def test_tag_top_artists(self): # Arrange tag = self.network.get_tag("blues") @@ -1459,7 +1340,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - @handle_lastfm_exceptions def test_country_top_artists(self): # Arrange country = self.network.get_country("Ukraine") @@ -1470,7 +1350,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - @handle_lastfm_exceptions def test_user_top_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1481,7 +1360,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - @handle_lastfm_exceptions def test_tag_top_albums(self): # Arrange tag = self.network.get_tag("blues") @@ -1492,7 +1370,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) - @handle_lastfm_exceptions def test_user_top_albums(self): # Arrange user = self.network.get_user("RJ") @@ -1503,7 +1380,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) - @handle_lastfm_exceptions def test_user_tagged_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1517,7 +1393,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) - @handle_lastfm_exceptions def test_user_tagged_albums(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1531,7 +1406,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) - @handle_lastfm_exceptions def test_user_tagged_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -1545,7 +1419,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) - @handle_lastfm_exceptions def test_user_subscriber(self): # Arrange subscriber = self.network.get_user("RJ") @@ -1559,7 +1432,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(subscriber_is_subscriber) self.assertFalse(non_subscriber_is_subscriber) - @handle_lastfm_exceptions def test_user_get_image(self): # Arrange user = self.network.get_user("RJ") @@ -1570,7 +1442,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertTrue(url.startswith("https://")) - @handle_lastfm_exceptions def test_user_get_library(self): # Arrange user = self.network.get_user(self.username) @@ -1581,7 +1452,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsInstance(library, pylast.Library) - @handle_lastfm_exceptions def test_caching(self): # Arrange user = self.network.get_user("RJ") @@ -1597,7 +1467,6 @@ class TestPyLast(unittest.TestCase): self.network.disable_caching() self.assertFalse(self.network.is_caching_enabled()) - @handle_lastfm_exceptions def test_create_playlist(self): # Arrange title = "Test playlist" @@ -1613,7 +1482,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(playlist.get_description(), "Testing") self.assertEqual(playlist.get_user(), lastfm_user) - @handle_lastfm_exceptions def test_empty_playlist_unstreamable(self): # Arrange title = "Empty playlist" @@ -1627,7 +1495,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(playlist.get_duration(), 0) self.assertFalse(playlist.is_streamable()) - @handle_lastfm_exceptions def test_big_playlist_is_streamable(self): # Arrange # Find a big playlist on Last.fm, eg "top 100 classick rock songs" @@ -1648,7 +1515,6 @@ class TestPyLast(unittest.TestCase): self.assertGreater(playlist.get_duration(), 0) self.assertTrue(playlist.is_streamable()) - @handle_lastfm_exceptions def test_add_track_to_playlist(self): # Arrange title = "One track playlist" @@ -1664,7 +1530,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(playlist.get_tracks()), 1) self.assertTrue(playlist.has_track(track)) - @handle_lastfm_exceptions def test_album_mbid(self): # Arrange mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" @@ -1678,7 +1543,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(album.title.lower(), "test") self.assertEqual(album_mbid, mbid) - @handle_lastfm_exceptions def test_artist_mbid(self): # Arrange mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" @@ -1690,7 +1554,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(artist, pylast.Artist) self.assertEqual(artist.name, "MusicBrainz Test Artist") - @handle_lastfm_exceptions def test_track_mbid(self): # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" @@ -1704,7 +1567,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(track.title, "first") self.assertEqual(track_mbid, mbid) - @handle_lastfm_exceptions def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1716,7 +1578,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(count, int) self.assertGreater(count, 0) - @handle_lastfm_exceptions def test_event_attendees(self): # Arrange user = self.network.get_user("RJ") @@ -1729,7 +1590,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(users, list) self.assertIsInstance(users[0], pylast.User) - @handle_lastfm_exceptions def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1748,7 +1608,6 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(found) - @handle_lastfm_exceptions def test_remove_tag_of_type_text(self): # Arrange tag = "testing" # text @@ -1767,7 +1626,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) - @handle_lastfm_exceptions def test_remove_tag_of_type_tag(self): # Arrange tag = pylast.Tag("testing", self.network) # Tag @@ -1786,7 +1644,6 @@ class TestPyLast(unittest.TestCase): break self.assertFalse(found) - @handle_lastfm_exceptions def test_remove_tags(self): # Arrange tags = ["removetag1", "removetag2"] @@ -1810,7 +1667,6 @@ class TestPyLast(unittest.TestCase): self.assertFalse(found1) self.assertFalse(found2) - @handle_lastfm_exceptions def test_set_tags(self): # Arrange tags = ["sometag1", "sometag2"] @@ -1835,7 +1691,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(found1) self.assertTrue(found2) - @handle_lastfm_exceptions def test_tracks_notequal(self): # Arrange track1 = pylast.Track("Test Artist", "test title", self.network) @@ -1845,7 +1700,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertNotEqual(track1, track2) - @handle_lastfm_exceptions def test_track_id(self): # Arrange track = pylast.Track("Test Artist", "test title", self.network) @@ -1857,7 +1711,6 @@ class TestPyLast(unittest.TestCase): self.skip_if_lastfm_api_broken(id) self.assertEqual(id, "14053327") - @handle_lastfm_exceptions def test_track_title_prop_caps(self): # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -1868,7 +1721,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(title, "test title") - @handle_lastfm_exceptions def test_track_listener_count(self): # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -1879,7 +1731,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertGreater(count, 21) - @handle_lastfm_exceptions def test_album_tracks(self): # Arrange album = pylast.Album("Test Artist", "Test Release", self.network) @@ -1894,7 +1745,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(len(tracks), 4) self.assertTrue(url.startswith("https://www.last.fm/music/test")) - @handle_lastfm_exceptions def test_tags(self): # Arrange tag1 = self.network.get_tag("blues") @@ -1915,7 +1765,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(tag1 != tag2) self.assertEqual(url, "https://www.last.fm/tag/blues") - @handle_lastfm_exceptions def test_tags_similar(self): # Arrange tag = self.network.get_tag("blues") @@ -1932,7 +1781,6 @@ class TestPyLast(unittest.TestCase): break self.assertTrue(found) - @handle_lastfm_exceptions def test_artists(self): # Arrange artist1 = self.network.get_artist("Radiohead") @@ -1956,7 +1804,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertIsInstance(streamable, bool) - @handle_lastfm_exceptions def test_events(self): # Arrange event_id_1 = 3162700 # Glasto 2013 @@ -1991,7 +1838,6 @@ class TestPyLast(unittest.TestCase): self.assertGreater(review_count, 0) self.assertGreater(attendance_count, 100) - @handle_lastfm_exceptions def test_countries(self): # Arrange country1 = pylast.Country("Italy", self.network) @@ -2010,7 +1856,6 @@ class TestPyLast(unittest.TestCase): self.assertTrue(country1 != country2) self.assertEqual(url, "https://www.last.fm/place/italy") - @handle_lastfm_exceptions def test_track_eq_none_is_false(self): # Arrange track1 = None @@ -2019,7 +1864,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(track1 == track2) - @handle_lastfm_exceptions def test_track_ne_none_is_true(self): # Arrange track1 = None @@ -2028,7 +1872,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(track1 != track2) - @handle_lastfm_exceptions def test_artist_eq_none_is_false(self): # Arrange artist1 = None @@ -2037,7 +1880,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(artist1 == artist2) - @handle_lastfm_exceptions def test_artist_ne_none_is_true(self): # Arrange artist1 = None @@ -2046,7 +1888,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(artist1 != artist2) - @handle_lastfm_exceptions def test_album_eq_none_is_false(self): # Arrange album1 = None @@ -2055,7 +1896,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(album1 == album2) - @handle_lastfm_exceptions def test_album_ne_none_is_true(self): # Arrange album1 = None @@ -2064,7 +1904,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(album1 != album2) - @handle_lastfm_exceptions def test_event_eq_none_is_false(self): # Arrange event1 = None @@ -2074,7 +1913,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertFalse(event1 == event2) - @handle_lastfm_exceptions def test_event_ne_none_is_true(self): # Arrange event1 = None @@ -2084,7 +1922,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(event1 != event2) - @handle_lastfm_exceptions def test_band_members(self): # Arrange artist = pylast.Artist("The Beatles", self.network) @@ -2096,7 +1933,6 @@ class TestPyLast(unittest.TestCase): self.skip_if_lastfm_api_broken(band_members) self.assertGreaterEqual(len(band_members), 4) - @handle_lastfm_exceptions def test_no_band_members(self): # Arrange artist = pylast.Artist("John Lennon", self.network) @@ -2107,7 +1943,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(band_members) - @handle_lastfm_exceptions def test_get_recent_tracks_from_to(self): # Arrange lastfm_user = self.network.get_user("RJ") @@ -2128,7 +1963,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - @handle_lastfm_exceptions def test_artist_get_correction(self): # Arrange artist = pylast.Artist("guns and roses", self.network) @@ -2139,7 +1973,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") - @handle_lastfm_exceptions def test_track_get_correction(self): # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) @@ -2150,7 +1983,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertEqual(corrected_track_name, "Mr. Brownstone") - @handle_lastfm_exceptions def test_track_with_no_mbid(self): # Arrange track = pylast.Track("Static-X", "Set It Off", self.network) From f419c39ef04f5d53bdd945586ceb0cef160161ec Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 18:03:00 +0300 Subject: [PATCH 327/768] Remove dead Last.fm library methods --- pylast/__init__.py | 122 ------------------------------------------- tests/test_pylast.py | 111 --------------------------------------- 2 files changed, 233 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index da167c6..46a61b5 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2433,86 +2433,8 @@ class Library(_BaseObject): def get_user(self): """Returns the user who owns this library.""" - return self.user - def add_album(self, album): - """Add an album to this library.""" - - params = self._get_params() - params["artist"] = album.get_artist().get_name() - params["album"] = album.get_name() - - self._request("library.addAlbum", False, params) - - def remove_album(self, album): - """Remove an album from this library.""" - - params = self._get_params() - params["artist"] = album.get_artist().get_name() - params["album"] = album.get_name() - - self._request(self.ws_prefix + ".removeAlbum", False, params) - - def add_artist(self, artist): - """Add an artist to this library.""" - - params = self._get_params() - if type(artist) == str: - params["artist"] = artist - else: - params["artist"] = artist.get_name() - - self._request(self.ws_prefix + ".addArtist", False, params) - - def remove_artist(self, artist): - """Remove an artist from this library.""" - - params = self._get_params() - if type(artist) == str: - params["artist"] = artist - else: - params["artist"] = artist.get_name() - - self._request(self.ws_prefix + ".removeArtist", False, params) - - def add_track(self, track): - """Add a track to this library.""" - - params = self._get_params() - params["track"] = track.get_title() - - self._request(self.ws_prefix + ".addTrack", False, params) - - def get_albums(self, artist=None, limit=50, cacheable=True): - """ - Returns a sequence of Album objects - If no artist is specified, it will return all, sorted by decreasing - play count. - If limit==None it will return all (may take a while) - """ - - params = self._get_params() - if artist: - params["artist"] = artist - - seq = [] - for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getAlbums", - cacheable, - params): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) - - seq.append(LibraryItem( - Album(artist, name, self.network), playcount, tagcount)) - - return seq - def get_artists(self, limit=50, cacheable=True): """ Returns a sequence of Album objects @@ -2535,50 +2457,6 @@ class Library(_BaseObject): return seq - def get_tracks(self, artist=None, album=None, limit=50, cacheable=True): - """ - Returns a sequence of Album objects - If limit==None it will return all (may take a while) - """ - - params = self._get_params() - if artist: - params["artist"] = artist - if album: - params["album"] = album - - seq = [] - for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getTracks", - cacheable, - params): - name = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) - - seq.append(LibraryItem( - Track(artist, name, self.network), playcount, tagcount)) - - return seq - - def remove_scrobble(self, artist, title, timestamp): - """Remove a scrobble from a user's Last.fm library. Parameters: - artist (Required) : The artist that composed the track - title (Required) : The name of the track - timestamp (Required) : The unix timestamp of the scrobble - that you wish to remove - """ - - params = self._get_params() - params["artist"] = artist - params["track"] = title - params["timestamp"] = timestamp - - self._request(self.ws_prefix + ".removeScrobble", False, params) - class Playlist(_BaseObject): """A Last.fm user playlist.""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 6a7799e..e5e415e 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -74,97 +74,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - def test_unscrobble(self): - # Arrange - artist = "Test Artist 2" - title = "Test Title 2" - timestamp = self.unix_timestamp() - library = pylast.Library(user=self.username, network=self.network) - self.network.scrobble(artist=artist, title=title, timestamp=timestamp) - lastfm_user = self.network.get_user(self.username) - - # Act - library.remove_scrobble( - artist=artist, title=title, timestamp=timestamp) - - # Assert - # limit=2 to ignore now-playing: - last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] - self.assertNotEqual(str(last_scrobble.timestamp), str(timestamp)) - - def test_add_album(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - album = self.network.get_album("Test Artist", "Test Album") - - # Act - library.add_album(album) - - # Assert - my_albums = library.get_albums() - for my_album in my_albums: - value = (album == my_album[0]) - if value: - break - self.assertTrue(value) - - def test_remove_album(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - # Pick an artist with plenty of albums - artist = self.network.get_top_artists(limit=1)[0].item - albums = artist.get_top_albums() - # Pick a random one to avoid problems running concurrent tests - album = choice(albums)[0] - library.add_album(album) - - # Act - library.remove_album(album) - - # Assert - my_albums = library.get_albums() - for my_album in my_albums: - value = (album == my_album[0]) - if value: - break - self.assertFalse(value) - - def test_add_artist(self): - # Arrange - artist = "Test Artist 2" - library = pylast.Library(user=self.username, network=self.network) - - # Act - library.add_artist(artist) - - # Assert - artists = library.get_artists() - for artist in artists: - value = (str(artist[0]) == "Test Artist 2") - if value: - break - self.assertTrue(value) - - def test_remove_artist(self): - # Arrange - # Get plenty of artists - artists = self.network.get_top_artists() - # Pick a random one to avoid problems running concurrent tests - my_artist = choice(artists).item - library = pylast.Library(user=self.username, network=self.network) - library.add_artist(my_artist) - - # Act - library.remove_artist(my_artist) - - # Assert - artists = library.get_artists() - for artist in artists: - value = (artist[0] == my_artist) - if value: - break - self.assertFalse(value) - def test_get_venue(self): # Arrange venue_name = "Last.fm Office" @@ -259,26 +168,6 @@ class TestPyLast(unittest.TestCase): self.assertNotEqual(str(loved.track.artist), "Test Artist") self.assertNotEqual(str(loved.track.title), "test title") - def test_get_100_albums(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - - # Act - albums = library.get_albums(limit=100) - - # Assert - self.assertGreaterEqual(len(albums), 0) - - def test_get_limitless_albums(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - - # Act - albums = library.get_albums(limit=None) - - # Assert - self.assertGreaterEqual(len(albums), 0) - def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) From 56e193d14903225964bedb88da959b20a2614019 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 18:11:48 +0300 Subject: [PATCH 328/768] Remove dead Last.fm playlist methods --- pylast/__init__.py | 219 ------------------------------------------- tests/test_pylast.py | 80 ---------------- 2 files changed, 299 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 46a61b5..f1b5880 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -296,24 +296,6 @@ class _Network(object): self.last_call_time = now - def create_new_playlist(self, title, description): - """ - Creates a playlist for the authenticated user and returns it - title: The title of the new playlist. - description: The description of the new playlist. - """ - - params = {} - params['title'] = title - params['description'] = description - - doc = _Request(self, 'playlist.create', params).execute(False) - - e_id = doc.getElementsByTagName("id")[0].firstChild.data - user = doc.getElementsByTagName('playlists')[0].getAttribute('user') - - return Playlist(user, e_id, self) - def get_top_artists(self, limit=None, cacheable=True): """Returns the most played artists as a sequence of TopItem objects.""" @@ -815,7 +797,6 @@ class LastFMNetwork(_Network): "artist": "music/%(artist)s", "event": "event/%(id)s", "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "group": "group/%(name)s", @@ -880,7 +861,6 @@ class LibreFMNetwork(_Network): "artist": "artist/%(artist)s", "event": "event/%(id)s", "country": "place/%(country_name)s", - "playlist": "user/%(user)s/library/playlists/%(appendix)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "group": "group/%(name)s", @@ -2458,148 +2438,6 @@ class Library(_BaseObject): return seq -class Playlist(_BaseObject): - """A Last.fm user playlist.""" - - id = None - user = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, user, playlist_id, network): - _BaseObject.__init__(self, network, "playlist") - - if isinstance(user, User): - self.user = user - else: - self.user = User(user, self.network) - - self.id = playlist_id - - @_string_output - def __str__(self): - return repr(self.user) + "'s playlist # " + repr(self.id) - - def _get_info_node(self): - """ - Returns the node from user.getPlaylists where this playlist's info is. - """ - - doc = self._request("user.getPlaylists", True) - - for node in doc.getElementsByTagName("playlist"): - if _extract(node, "id") == str(self.get_id()): - return node - - def _get_params(self): - return {'user': self.user.get_name(), 'playlistID': self.get_id()} - - def get_id(self): - """Returns the playlist ID.""" - - return self.id - - def get_user(self): - """Returns the owner user of this playlist.""" - - return self.user - - def get_tracks(self): - """Returns a list of the tracks on this user playlist.""" - - uri = _unicode('lastfm://playlist/%s') % self.get_id() - - return XSPF(uri, self.network).get_tracks() - - def add_track(self, track): - """Adds a Track to this Playlist.""" - - params = self._get_params() - params['artist'] = track.get_artist().get_name() - params['track'] = track.get_title() - - self._request('playlist.addTrack', False, params) - - def get_title(self): - """Returns the title of this playlist.""" - - return _extract(self._get_info_node(), "title") - - def get_creation_date(self): - """Returns the creation date of this playlist.""" - - return _extract(self._get_info_node(), "date") - - def get_size(self): - """Returns the number of tracks in this playlist.""" - - return _number(_extract(self._get_info_node(), "size")) - - def get_description(self): - """Returns the description of this playlist.""" - - return _extract(self._get_info_node(), "description") - - def get_duration(self): - """Returns the duration of this playlist in milliseconds.""" - - return _number(_extract(self._get_info_node(), "duration")) - - def is_streamable(self): - """ - Returns True if the playlist is streamable. - For a playlist to be streamable, it needs at least 45 tracks by 15 - different artists.""" - - if _extract(self._get_info_node(), "streamable") == '1': - return True - else: - return False - - def has_track(self, track): - """Checks to see if track is already in the playlist. - * track: Any Track object. - """ - - return track in self.get_tracks() - - def get_cover_image(self, size=COVER_EXTRA_LARGE): - """ - Returns a uri to the cover image - size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - return _extract(self._get_info_node(), "image")[size] - - def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the playlist on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - english_url = _extract(self._get_info_node(), "url") - appendix = english_url[english_url.rfind("/") + 1:] - - return self.network._get_url(domain_name, "playlist") % { - 'appendix': appendix, "user": self.get_user().get_name()} - - class Tag(_BaseObject, _Chartable): """A Last.fm object tag.""" @@ -2895,51 +2733,6 @@ class Group(_BaseObject, _Chartable): return users -class XSPF(_BaseObject): - "A Last.fm XSPF playlist.""" - - uri = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, uri, network): - _BaseObject.__init__(self, network, None) - - self.uri = uri - - def _get_params(self): - return {'playlistURL': self.get_uri()} - - @_string_output - def __str__(self): - return self.get_uri() - - def __eq__(self, other): - return self.get_uri() == other.get_uri() - - def __ne__(self, other): - return self.get_uri() != other.get_uri() - - def get_uri(self): - """Returns the Last.fm playlist URI. """ - - return self.uri - - def get_tracks(self): - """Returns the tracks on this playlist.""" - - doc = self._request('playlist.fetch', True) - - seq = [] - for node in doc.getElementsByTagName('track'): - title = _extract(node, 'title') - artist = _extract(node, 'creator') - - seq.append(Track(artist, title, self.network)) - - return seq - - class User(_BaseObject, _Chartable): """A Last.fm user.""" @@ -3101,18 +2894,6 @@ class User(_BaseObject, _Chartable): return seq - def get_playlists(self): - """Returns a list of Playlists that this user owns.""" - - doc = self._request(self.ws_prefix + ".getPlaylists", True) - - playlists = [] - for playlist_id in _extract_all(doc, "id"): - playlists.append( - Playlist(self.get_name(), playlist_id, self.network)) - - return playlists - def get_now_playing(self): """ Returns the currently playing track, or None if nothing is playing. diff --git a/tests/test_pylast.py b/tests/test_pylast.py index e5e415e..1739bd9 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -5,7 +5,6 @@ Integration (not unit) tests for pylast.py from flaky import flaky import os import pytest -from random import choice import time import unittest @@ -302,14 +301,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(library) - def test_playlist_is_hashable(self): - # Arrange - playlist = pylast.Playlist( - user="RJ", playlist_id="1k1qp_doglist", network=self.network) - - # Act/Assert - self.helper_is_thing_hashable(playlist) - def test_tag_is_hashable(self): # Arrange tag = self.network.get_top_tags(limit=1)[0] @@ -341,14 +332,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(venue) - def test_xspf_is_hashable(self): - # Arrange - xspf = pylast.XSPF( - uri="lastfm://playlist/1k1qp_doglist", network=self.network) - - # Act/Assert - self.helper_is_thing_hashable(xspf) - def test_invalid_xml(self): # Arrange # Currently causes PCDATA invalid Char value 25 @@ -1356,69 +1339,6 @@ class TestPyLast(unittest.TestCase): self.network.disable_caching() self.assertFalse(self.network.is_caching_enabled()) - def test_create_playlist(self): - # Arrange - title = "Test playlist" - description = "Testing" - lastfm_user = self.network.get_user(self.username) - - # Act - playlist = self.network.create_new_playlist(title, description) - - # Assert - self.assertIsInstance(playlist, pylast.Playlist) - self.assertEqual(playlist.get_title(), "Test playlist") - self.assertEqual(playlist.get_description(), "Testing") - self.assertEqual(playlist.get_user(), lastfm_user) - - def test_empty_playlist_unstreamable(self): - # Arrange - title = "Empty playlist" - description = "Unstreamable" - - # Act - playlist = self.network.create_new_playlist(title, description) - - # Assert - self.assertEqual(playlist.get_size(), 0) - self.assertEqual(playlist.get_duration(), 0) - self.assertFalse(playlist.is_streamable()) - - def test_big_playlist_is_streamable(self): - # Arrange - # Find a big playlist on Last.fm, eg "top 100 classick rock songs" - user = "kaxior" - id = 10417943 - playlist = pylast.Playlist(user, id, self.network) - self.assertEqual( - playlist.get_url(), - "https://www.last.fm/user/kaxior/library/" - "playlists/67ajb_top_100_classick_rock_songs") - - # Act - # Nothing - - # Assert - self.assertIsInstance(playlist, pylast.Playlist) - self.assertGreaterEqual(playlist.get_size(), 45) - self.assertGreater(playlist.get_duration(), 0) - self.assertTrue(playlist.is_streamable()) - - def test_add_track_to_playlist(self): - # Arrange - title = "One track playlist" - description = "Testing" - playlist = self.network.create_new_playlist(title, description) - track = pylast.Track("Test Artist", "test title", self.network) - - # Act - playlist.add_track(track) - - # Assert - self.assertEqual(playlist.get_size(), 1) - self.assertEqual(len(playlist.get_tracks()), 1) - self.assertTrue(playlist.has_track(track)) - def test_album_mbid(self): # Arrange mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" From 6d738d3f431a6113f100e23e97c50007793ab466 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 18:24:13 +0300 Subject: [PATCH 329/768] Remove dead Last.fm artist/shout methods --- pylast/__init__.py | 92 ---------------------------- tests/test_pylast.py | 141 ------------------------------------------- 2 files changed, 233 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f1b5880..f0436d1 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -708,39 +708,6 @@ class _Network(object): if remaining_tracks: self.scrobble_many(remaining_tracks) - def get_play_links(self, link_type, things, cacheable=True): - method = link_type + ".getPlaylinks" - params = {} - - for i, thing in enumerate(things): - if link_type == "artist": - params['artist[' + str(i) + ']'] = thing - elif link_type == "album": - params['artist[' + str(i) + ']'] = thing.artist - params['album[' + str(i) + ']'] = thing.title - elif link_type == "track": - params['artist[' + str(i) + ']'] = thing.artist - params['track[' + str(i) + ']'] = thing.title - - doc = _Request(self, method, params).execute(cacheable) - - seq = [] - - for node in doc.getElementsByTagName("externalids"): - spotify = _extract(node, "spotify") - seq.append(spotify) - - return seq - - def get_artist_play_links(self, artists, cacheable=True): - return self.get_play_links("artist", artists, cacheable) - - def get_album_play_links(self, albums, cacheable=True): - return self.get_play_links("album", albums, cacheable) - - def get_track_play_links(self, tracks, cacheable=True): - return self.get_play_links("track", tracks, cacheable) - class LastFMNetwork(_Network): @@ -1169,8 +1136,6 @@ ImageSizes = collections.namedtuple( Image = collections.namedtuple( "Image", [ "title", "url", "dateadded", "format", "owner", "sizes", "votes"]) -Shout = collections.namedtuple( - "Shout", ["body", "author", "date"]) def _string_output(func): @@ -1301,26 +1266,6 @@ class _BaseObject(object): return _extract(node, section) - def get_shouts(self, limit=50, cacheable=False): - """ - Returns a sequence of Shout objects - """ - - shouts = [] - for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getShouts", - cacheable): - shouts.append( - Shout( - _extract(node, "body"), - User(_extract(node, "author"), self.network), - _extract(node, "date") - ) - ) - return shouts - class _Chartable(object): """Common functions for classes with charts.""" @@ -1887,13 +1832,6 @@ class Artist(_BaseObject, _Taggable): """Returns the content of the artist's biography.""" return self.get_bio("content", language) - def get_upcoming_events(self): - """Returns a list of the upcoming Events for this artist.""" - - doc = self._request(self.ws_prefix + '.getEvents', True) - - return _extract_events_from_doc(doc, self.network) - def get_similar(self, limit=None): """Returns the similar artists on the network.""" @@ -1954,16 +1892,6 @@ class Artist(_BaseObject, _Taggable): return self.network._get_url( domain_name, "artist") % {'artist': artist} - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request("artist.Shout", False, params) - def get_band_members(self): """Returns a list of band members or None if unknown.""" @@ -2137,16 +2065,6 @@ class Event(_BaseObject): return self.network._get_url( domain_name, "event") % {'id': self.get_id()} - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request("event.Shout", False, params) - class Country(_BaseObject): """A country at Last.fm.""" @@ -3232,16 +3150,6 @@ class User(_BaseObject, _Chartable): return Library(self, self.network) - def shout(self, message): - """ - Post a shout - """ - - params = self._get_params() - params["message"] = message - - self._request(self.ws_prefix + ".Shout", False, params) - class AuthenticatedUser(User): def __init__(self, network): diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 1739bd9..717c1be 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -464,13 +464,6 @@ class TestPyLast(unittest.TestCase): for event in events[:2]: # checking first two should be enough self.assertIsInstance(event.get_headliner(), pylast.Artist) - def test_artist_upcoming_events_returns_valid_ids(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act/Assert - self.helper_upcoming_events_have_valid_ids(artist) - def test_user_past_events_returns_valid_ids(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -646,28 +639,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) - def test_cacheable_artist_get_shouts(self): - # Arrange - artist = self.network.get_artist("Test Artist") - - # Act/Assert - self.helper_validate_cacheable(artist, "get_shouts") - - def test_cacheable_event_get_shouts(self): - # Arrange - user = self.network.get_user("RJ") - event = user.get_past_events(limit=1)[0] - - # Act/Assert - self.helper_validate_cacheable(event, "get_shouts") - - def test_cacheable_track_get_shouts(self): - # Arrange - track = self.network.get_top_tracks()[0].item - - # Act/Assert - self.helper_validate_cacheable(track, "get_shouts") - def test_cacheable_group_get_members(self): # Arrange group = self.network.get_group("Audioscrobbler Beta") @@ -708,7 +679,6 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") self.helper_validate_cacheable(lastfm_user, "get_recommended_artists") self.helper_validate_cacheable(lastfm_user, "get_recommended_events") - self.helper_validate_cacheable(lastfm_user, "get_shouts") def test_geo_get_events_in_location(self): # Arrange @@ -851,48 +821,6 @@ class TestPyLast(unittest.TestCase): metro, pylast.Metro("Wellington", "New Zealand", self.network)) - def test_get_album_play_links(self): - # Arrange - album1 = self.network.get_album("Portishead", "Dummy") - album2 = self.network.get_album("Radiohead", "OK Computer") - albums = [album1, album2] - - # Act - links = self.network.get_album_play_links(albums) - - # Assert - self.assertIsInstance(links, list) - self.assertEqual(len(links), 2) - self.assertIn("spotify:album:", links[0]) - self.assertIn("spotify:album:", links[1]) - - def test_get_artist_play_links(self): - # Arrange - artists = ["Portishead", "Radiohead"] - # Act - links = self.network.get_artist_play_links(artists) - - # Assert - self.assertIsInstance(links, list) - self.assertEqual(len(links), 2) - self.assertIn("spotify:artist:", links[0]) - self.assertIn("spotify:artist:", links[1]) - - def test_get_track_play_links(self): - # Arrange - track1 = self.network.get_track(artist="Portishead", title="Mysterons") - track2 = self.network.get_track(artist="Radiohead", title="Creep") - tracks = [track1, track2] - - # Act - links = self.network.get_track_play_links(tracks) - - # Assert - self.assertIsInstance(links, list) - self.assertEqual(len(links), 2) - self.assertIn("spotify:track:", links[0]) - self.assertIn("spotify:track:", links[1]) - def helper_at_least_one_thing_in_top_list(self, things, expected_type): # Assert self.assertGreater(len(things), 1) @@ -1105,60 +1033,6 @@ class TestPyLast(unittest.TestCase): # album/artist/event/track/user - def test_album_shouts(self): - # Arrange - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - album = artist.get_top_albums(limit=1)[0].item - - # Act - shouts = album.get_shouts(limit=2) - - # Assert - self.helper_two_things_in_list(shouts, pylast.Shout) - - def test_artist_shouts(self): - # Arrange - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - shouts = artist.get_shouts(limit=2) - - # Assert - self.helper_two_things_in_list(shouts, pylast.Shout) - - def test_event_shouts(self): - # Arrange - event_id = 3478520 # Glasto 2014 - event = pylast.Event(event_id, self.network) - - # Act - shouts = event.get_shouts(limit=2) - - # Assert - self.helper_two_things_in_list(shouts, pylast.Shout) - - def test_track_shouts(self): - # Arrange - track = self.network.get_track("The Cinematic Orchestra", "Postlude") - - # Act - shouts = track.get_shouts(limit=2) - - # Assert - self.helper_two_things_in_list(shouts, pylast.Shout) - - def test_user_shouts(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - shouts = user.get_shouts(limit=2) - - # Assert - self.helper_two_things_in_list(shouts, pylast.Shout) - def test_album_data(self): # Arrange thing = self.network.get_album("Test Artist", "Test Album") @@ -1324,21 +1198,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsInstance(library, pylast.Library) - def test_caching(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - self.network.enable_caching() - shouts1 = user.get_shouts(limit=1, cacheable=True) - shouts2 = user.get_shouts(limit=1, cacheable=True) - - # Assert - self.assertTrue(self.network.is_caching_enabled()) - self.assertEqual(shouts1, shouts2) - self.network.disable_caching() - self.assertFalse(self.network.is_caching_enabled()) - def test_album_mbid(self): # Arrange mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" From 454c519fd9b1494c51606f826057b72dd8b8de70 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 18:29:12 +0300 Subject: [PATCH 330/768] Remove dead Last.fm group methods --- pylast/__init__.py | 86 ++------------------------------------------ tests/test_pylast.py | 23 ------------ 2 files changed, 3 insertions(+), 106 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f0436d1..fe9b0bf 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -241,13 +241,6 @@ class _Network(object): return Metro(metro_name, country_name, self) - def get_group(self, name): - """ - Returns a Group object - """ - - return Group(name, self) - def get_user(self, username): """ Returns a user object @@ -766,7 +759,6 @@ class LastFMNetwork(_Network): "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", "user": "user/%(name)s", } ) @@ -830,7 +822,6 @@ class LibreFMNetwork(_Network): "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", - "group": "group/%(name)s", "user": "user/%(name)s", } ) @@ -1288,7 +1279,7 @@ class _Chartable(object): """ Returns the weekly album charts for the week starting from the from_date value to the to_date value. - Only for Group or User. + Only for User. """ return self.get_weekly_charts("album", from_date, to_date) @@ -1296,7 +1287,7 @@ class _Chartable(object): """ Returns the weekly artist charts for the week starting from the from_date value to the to_date value. - Only for Group, Tag or User. + Only for Tag or User. """ return self.get_weekly_charts("artist", from_date, to_date) @@ -1304,7 +1295,7 @@ class _Chartable(object): """ Returns the weekly track charts for the week starting from the from_date value to the to_date value. - Only for Group or User. + Only for User. """ return self.get_weekly_charts("track", from_date, to_date) @@ -2580,77 +2571,6 @@ class Track(_Opus): 'artist': artist, 'title': title} -class Group(_BaseObject, _Chartable): - """A Last.fm group.""" - - name = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, name, network): - _BaseObject.__init__(self, network, 'group') - _Chartable.__init__(self, 'group') - - self.name = name - - def __repr__(self): - return "pylast.Group(%s, %s)" % (repr(self.name), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() - - def __eq__(self, other): - return self.get_name().lower() == other.get_name().lower() - - def __ne__(self, other): - return self.get_name() != other.get_name() - - def _get_params(self): - return {self.ws_prefix: self.get_name()} - - def get_name(self): - """Returns the group name. """ - return self.name - - def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the group page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - name = _url_safe(self.get_name()) - - return self.network._get_url(domain_name, "group") % {'name': name} - - def get_members(self, limit=50, cacheable=False): - """ - Returns a sequence of User objects - if limit==None it will return all - """ - - nodes = _collect_nodes( - limit, self, self.ws_prefix + ".getMembers", cacheable) - - users = [] - - for node in nodes: - users.append(User(_extract(node, "name"), self.network)) - - return users - - class User(_BaseObject, _Chartable): """A Last.fm user.""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 717c1be..0146174 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -287,13 +287,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(event) - def test_group_is_hashable(self): - # Arrange - group = self.network.get_group("Audioscrobbler Beta") - - # Act/Assert - self.helper_is_thing_hashable(group) - def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -639,13 +632,6 @@ class TestPyLast(unittest.TestCase): # Assert self.helper_validate_results(result1, result2, result3) - def test_cacheable_group_get_members(self): - # Arrange - group = self.network.get_group("Audioscrobbler Beta") - - # Act/Assert - self.helper_validate_cacheable(group, "get_members") - def test_cacheable_library(self): # Arrange library = pylast.Library(self.username, self.network) @@ -987,15 +973,6 @@ class TestPyLast(unittest.TestCase): self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) - def test_group_charts(self): - # Arrange - group = self.network.get_group("mnml") - dates = group.get_weekly_chart_dates() - self.helper_dates_valid(dates) - - # Act/Assert - self.helper_get_assert_charts(group, dates[-2]) - def test_tag_charts(self): # Arrange tag = self.network.get_tag("rock") From 61216f73c0b95dc4ac7760db8ab75a01f62b7108 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 Sep 2017 18:36:02 +0300 Subject: [PATCH 331/768] Remove dead Last.fm artist/user methods --- tests/test_pylast.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 0146174..7cfbb6c 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -637,9 +637,7 @@ class TestPyLast(unittest.TestCase): library = pylast.Library(self.username, self.network) # Act/Assert - self.helper_validate_cacheable(library, "get_albums") self.helper_validate_cacheable(library, "get_artists") - self.helper_validate_cacheable(library, "get_tracks") def test_cacheable_user_artist_tracks(self): # Arrange @@ -660,11 +658,7 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_validate_cacheable(lastfm_user, "get_friends") self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") - self.helper_validate_cacheable(lastfm_user, "get_neighbours") - self.helper_validate_cacheable(lastfm_user, "get_past_events") self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") - self.helper_validate_cacheable(lastfm_user, "get_recommended_artists") - self.helper_validate_cacheable(lastfm_user, "get_recommended_events") def test_geo_get_events_in_location(self): # Arrange From 230439f52f1d539144252d1d81848880405c17e8 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 26 Sep 2017 08:38:32 +0300 Subject: [PATCH 332/768] Remove dead Last.fm event/venu methods --- pylast/__init__.py | 367 +------------------------------------------ tests/test_pylast.py | 194 +---------------------- 2 files changed, 3 insertions(+), 558 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fe9b0bf..f31be7b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -67,10 +67,6 @@ STATUS_INVALID_SIGNATURE = 13 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 -EVENT_ATTENDING = '0' -EVENT_MAYBE_ATTENDING = '1' -EVENT_NOT_ATTENDING = '2' - PERIOD_OVERALL = 'overall' PERIOD_7DAYS = '7day' PERIOD_1MONTH = '1month' @@ -336,50 +332,6 @@ class _Network(object): return seq - def get_geo_events( - self, longitude=None, latitude=None, location=None, distance=None, - tag=None, festivalsonly=None, limit=None, cacheable=True): - """ - Returns all events in a specific location by country or city name. - Parameters: - longitude (Optional) : Specifies a longitude value to retrieve events - for (service returns nearby events by default) - latitude (Optional) : Specifies a latitude value to retrieve events for - (service returns nearby events by default) - location (Optional) : Specifies a location to retrieve events for - (service returns nearby events by default) - distance (Optional) : Find events within a specified radius - (in kilometres) - tag (Optional) : Specifies a tag to filter by. - festivalsonly[0|1] (Optional) : Whether only festivals should be - returned, or all events. - limit (Optional) : The number of results to fetch per page. - Defaults to 10. - """ - - params = {} - - if longitude: - params["long"] = longitude - if latitude: - params["lat"] = latitude - if location: - params["location"] = location - if limit: - params["limit"] = limit - if distance: - params["distance"] = distance - if tag: - params["tag"] = tag - if festivalsonly: - params["festivalsonly"] = 1 - elif not festivalsonly: - params["festivalsonly"] = 0 - - doc = _Request(self, "geo.getEvents", params).execute(cacheable) - - return _extract_events_from_doc(doc, self) - def get_metro_weekly_chart_dates(self, cacheable=True): """ Returns a list of From and To tuples for the available metro charts. @@ -554,14 +506,6 @@ class _Network(object): return TrackSearch(artist_name, track_name, self) - def search_for_venue(self, venue_name, country_name): - """Searches of a venue by its name and its country. Set country_name to - an empty string if not available. - Returns a VenueSearch object. - Use get_next_page() to retrieve sequences of results.""" - - return VenueSearch(venue_name, country_name, self) - def get_track_by_mbid(self, mbid): """Looks up a track by its MusicBrainz ID""" @@ -755,7 +699,6 @@ class LastFMNetwork(_Network): urls={ "album": "music/%(artist)s/%(album)s", "artist": "music/%(artist)s", - "event": "event/%(id)s", "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", @@ -818,7 +761,6 @@ class LibreFMNetwork(_Network): urls={ "album": "artist/%(artist)s/album/%(album)s", "artist": "artist/%(artist)s", - "event": "event/%(id)s", "country": "place/%(country_name)s", "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", @@ -1195,7 +1137,7 @@ class _BaseObject(object): * users [User|str,]: A list that can contain usernames, emails, User objects, or all of them. * message str: A message to include in the recommendation message. - Only for Artist/Event/Track. + Only for Artist/Track. """ # Last.fm currently accepts a max of 10 recipient at a time @@ -1895,168 +1837,6 @@ class Artist(_BaseObject, _Taggable): return names -class Event(_BaseObject): - """An event.""" - - id = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, event_id, network): - _BaseObject.__init__(self, network, 'event') - - self.id = event_id - - def __repr__(self): - return "pylast.Event(%s, %s)" % (repr(self.id), repr(self.network)) - - @_string_output - def __str__(self): - return "Event #" + str(self.get_id()) - - def __eq__(self, other): - if type(self) is type(other): - return self.get_id() == other.get_id() - else: - return False - - def __ne__(self, other): - return not self.__eq__(other) - - def _get_params(self): - return {'event': self.get_id()} - - def attend(self, attending_status): - """Sets the attending status. - * attending_status: The attending status. Possible values: - o EVENT_ATTENDING - o EVENT_MAYBE_ATTENDING - o EVENT_NOT_ATTENDING - """ - - params = self._get_params() - params['status'] = attending_status - - self._request('event.attend', False, params) - - def get_attendees(self): - """ - Get a list of attendees for an event - """ - - doc = self._request("event.getAttendees", False) - - users = [] - for name in _extract_all(doc, "name"): - users.append(User(name, self.network)) - - return users - - def get_id(self): - """Returns the id of the event on the network. """ - - return self.id - - def get_title(self): - """Returns the title of the event. """ - - doc = self._request("event.getInfo", True) - - return _extract(doc, "title") - - def get_headliner(self): - """Returns the headliner of the event. """ - - doc = self._request("event.getInfo", True) - - return Artist(_extract(doc, "headliner"), self.network) - - def get_artists(self): - """Returns a list of the participating Artists. """ - - doc = self._request("event.getInfo", True) - names = _extract_all(doc, "artist") - - artists = [] - for name in names: - artists.append(Artist(name, self.network)) - - return artists - - def get_venue(self): - """Returns the venue where the event is held.""" - - doc = self._request("event.getInfo", True) - - v = doc.getElementsByTagName("venue")[0] - venue_id = _number(_extract(v, "id")) - - return Venue(venue_id, self.network, venue_element=v) - - def get_start_date(self): - """Returns the date when the event starts.""" - - doc = self._request("event.getInfo", True) - - return _extract(doc, "startDate") - - def get_description(self): - """Returns the description of the event. """ - - doc = self._request("event.getInfo", True) - - return _extract(doc, "description") - - def get_cover_image(self, size=COVER_MEGA): - """ - Returns a uri to the cover image - size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL - """ - - doc = self._request("event.getInfo", True) - - return _extract_all(doc, "image")[size] - - def get_attendance_count(self): - """Returns the number of attending people. """ - - doc = self._request("event.getInfo", True) - - return _number(_extract(doc, "attendance")) - - def get_review_count(self): - """Returns the number of available reviews for this event. """ - - doc = self._request("event.getInfo", True) - - return _number(_extract(doc, "reviews")) - - def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the event page on the network. - * domain_name: The network's language domain. Possible values: - o DOMAIN_ENGLISH - o DOMAIN_GERMAN - o DOMAIN_SPANISH - o DOMAIN_FRENCH - o DOMAIN_ITALIAN - o DOMAIN_POLISH - o DOMAIN_PORTUGUESE - o DOMAIN_SWEDISH - o DOMAIN_TURKISH - o DOMAIN_RUSSIAN - o DOMAIN_JAPANESE - o DOMAIN_CHINESE - """ - - return self.network._get_url( - domain_name, "event") % {'id': self.get_id()} - - class Country(_BaseObject): """A country at Last.fm.""" @@ -2110,7 +1890,7 @@ class Country(_BaseObject): "getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the event page on the network. + """Returns the url of the country page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2615,13 +2395,6 @@ class User(_BaseObject, _Chartable): return self.name - def get_upcoming_events(self): - """Returns all the upcoming events for this user.""" - - doc = self._request(self.ws_prefix + '.getEvents', True) - - return _extract_events_from_doc(doc, self.network) - def get_artist_tracks(self, artist, cacheable=False): """ Get a list of tracks by a given artist scrobbled by this user, @@ -2716,22 +2489,6 @@ class User(_BaseObject, _Chartable): return seq - def get_past_events(self, limit=50, cacheable=False): - """ - Returns a sequence of Event objects - if limit==None it will return all - """ - - seq = [] - for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getPastEvents", - cacheable): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq - def get_now_playing(self): """ Returns the currently playing track, or None if nothing is playing. @@ -3086,19 +2843,6 @@ class AuthenticatedUser(User): self.name = _extract(doc, "name") return self.name - def get_recommended_events(self, limit=50, cacheable=False): - """ - Returns a sequence of Event objects - if limit==None it will return all - """ - - seq = [] - for node in _collect_nodes( - limit, self, "user.getRecommendedEvents", cacheable): - seq.append(Event(_extract(node, "id"), self.network)) - - return seq - def get_recommended_artists(self, limit=50, cacheable=False): """ Returns a sequence of Artist objects @@ -3247,106 +2991,6 @@ class TrackSearch(_Search): return seq -class VenueSearch(_Search): - """ - Search for a venue by its name. If you don't want to narrow the results - down by specifying a country, set it to empty string. - """ - - def __init__(self, venue_name, country_name, network): - - _Search.__init__( - self, - "venue", - {"venue": venue_name, "country": country_name}, - network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Track objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("venue"): - seq.append(Venue(_extract(node, "id"), self.network)) - - return seq - - -class Venue(_BaseObject): - """A venue where events are held.""" - - # TODO: waiting for a venue.getInfo web service to use. - # TODO: As an intermediate use case, can pass the venue DOM element when - # using Event.get_venue() to populate the venue info, if the venue.getInfo - # API call becomes available this workaround should be removed - - id = None - info = None - name = None - location = None - url = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, netword_id, network, venue_element=None): - _BaseObject.__init__(self, network, "venue") - - self.id = _number(netword_id) - if venue_element is not None: - self.info = _extract_element_tree(venue_element) - self.name = self.info.get('name') - self.url = self.info.get('url') - self.location = self.info.get('location') - - def __repr__(self): - return "pylast.Venue(%s, %s)" % (repr(self.id), repr(self.network)) - - @_string_output - def __str__(self): - return "Venue #" + str(self.id) - - def __eq__(self, other): - return self.get_id() == other.get_id() - - def _get_params(self): - return {self.ws_prefix: self.get_id()} - - def get_id(self): - """Returns the id of the venue.""" - - return self.id - - def get_name(self): - """Returns the name of the venue.""" - - return self.name - - def get_url(self): - """Returns the URL of the venue page.""" - - return self.url - - def get_location(self): - """Returns the location of the venue (dictionary).""" - - return self.location - - def get_upcoming_events(self): - """Returns the upcoming events in this venue.""" - - doc = self._request(self.ws_prefix + ".getEvents", True) - - return _extract_events_from_doc(doc, self.network) - - def get_past_events(self): - """Returns the past events held in this venue.""" - - doc = self._request(self.ws_prefix + ".getEvents", True) - - return _extract_events_from_doc(doc, self.network) - - def md5(text): """Returns the md5 hash of a string.""" @@ -3534,13 +3178,6 @@ def _extract_tracks(doc, network): return seq -def _extract_events_from_doc(doc, network): - events = [] - for node in doc.getElementsByTagName("event"): - events.append(Event(_extract(node, "id"), network)) - return events - - def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a url.""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 7cfbb6c..21671ff 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -73,18 +73,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - def test_get_venue(self): - # Arrange - venue_name = "Last.fm Office" - country_name = "United Kingdom" - - # Act - venue_search = self.network.search_for_venue(venue_name, country_name) - venue = venue_search.get_next_page()[0] - - # Assert - self.assertEqual(str(venue.id), "8778225") - def test_get_user_registration(self): # Arrange username = "RJ" @@ -279,14 +267,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(metro) - def test_event_is_hashable(self): - # Arrange - user = self.network.get_user("RJ") - event = user.get_past_events(limit=1)[0] - - # Act/Assert - self.helper_is_thing_hashable(event) - def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -317,14 +297,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(user) - def test_venue_is_hashable(self): - # Arrange - venue_id = "8778225" # Last.fm office - venue = pylast.Venue(venue_id, self.network) - - # Act/Assert - self.helper_is_thing_hashable(venue) - def test_invalid_xml(self): # Arrange # Currently causes PCDATA invalid Char value 25 @@ -436,67 +408,6 @@ class TestPyLast(unittest.TestCase): # # Assert # self.assertGreaterEqual(len(tracks), 0) - def helper_past_events_have_valid_ids(self, thing): - # Act - events = thing.get_past_events() - - # Assert - self.helper_assert_events_have_valid_ids(events) - - def helper_upcoming_events_have_valid_ids(self, thing): - # Act - events = thing.get_upcoming_events() - - # Assert - self.helper_assert_events_have_valid_ids(events) - - def helper_assert_events_have_valid_ids(self, events): - # Assert - # If fails, add past/future event for user/Test Artist: - self.assertGreaterEqual(len(events), 1) - for event in events[:2]: # checking first two should be enough - self.assertIsInstance(event.get_headliner(), pylast.Artist) - - def test_user_past_events_returns_valid_ids(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act/Assert - self.helper_past_events_have_valid_ids(lastfm_user) - - def test_user_recommended_events_returns_valid_ids(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - events = lastfm_user.get_upcoming_events() - - # Assert - self.helper_assert_events_have_valid_ids(events) - - def test_user_upcoming_events_returns_valid_ids(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act/Assert - self.helper_upcoming_events_have_valid_ids(lastfm_user) - - def test_venue_past_events_returns_valid_ids(self): - # Arrange - venue_id = "8778225" # Last.fm office - venue = pylast.Venue(venue_id, self.network) - - # Act/Assert - self.helper_past_events_have_valid_ids(venue) - - def test_venue_upcoming_events_returns_valid_ids(self): - # Arrange - venue_id = "8778225" # Last.fm office - venue = pylast.Venue(venue_id, self.network) - - # Act/Assert - self.helper_upcoming_events_have_valid_ids(venue) - def test_pickle(self): # Arrange import pickle @@ -660,43 +571,6 @@ class TestPyLast(unittest.TestCase): self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") - def test_geo_get_events_in_location(self): - # Arrange - # Act - events = self.network.get_geo_events( - location="London", tag="blues", limit=1) - - # Assert - self.assertEqual(len(events), 1) - event = events[0] - self.assertIsInstance(event, pylast.Event) - self.assertIn(event.get_venue().location['city'], - ["London", "Camden"]) - - def test_geo_get_events_in_latlong(self): - # Arrange - # Act - events = self.network.get_geo_events( - latitude=53.466667, longitude=-2.233333, distance=5, limit=1) - - # Assert - self.assertEqual(len(events), 1) - event = events[0] - self.assertIsInstance(event, pylast.Event) - self.assertEqual(event.get_venue().location['city'], "Manchester") - - def test_geo_get_events_festival(self): - # Arrange - # Act - events = self.network.get_geo_events( - location="Reading", festivalsonly=True, limit=1) - - # Assert - self.assertEqual(len(events), 1) - event = events[0] - self.assertIsInstance(event, pylast.Event) - self.assertEqual(event.get_venue().location['city'], "Reading") - def helper_dates_valid(self, dates): # Assert self.assertGreaterEqual(len(dates), 1) @@ -992,17 +866,15 @@ class TestPyLast(unittest.TestCase): # spam_message = "Dig the krazee sound!" # artist = self.network.get_top_artists(limit=1)[0].item # track = artist.get_top_tracks(limit=1)[0].item - # event = artist.get_upcoming_events()[0] # # Act # artist.share(users_to_spam, spam_message) # track.share(users_to_spam, spam_message) - # event.share(users_to_spam, spam_message) # Assert # Check inbox for spam! - # album/artist/event/track/user + # album/artist/track/user def test_album_data(self): # Arrange @@ -1217,18 +1089,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(count, int) self.assertGreater(count, 0) - def test_event_attendees(self): - # Arrange - user = self.network.get_user("RJ") - event = user.get_past_events(limit=1)[0] - - # Act - users = event.get_attendees() - - # Assert - self.assertIsInstance(users, list) - self.assertIsInstance(users[0], pylast.User) - def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1443,40 +1303,6 @@ class TestPyLast(unittest.TestCase): self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") self.assertIsInstance(streamable, bool) - def test_events(self): - # Arrange - event_id_1 = 3162700 # Glasto 2013 - event_id_2 = 3478520 # Glasto 2014 - event1 = pylast.Event(event_id_1, self.network) - event2 = pylast.Event(event_id_2, self.network) - - # Act - text = str(event1) - rep = repr(event1) - title = event1.get_title() - artists = event1.get_artists() - start = event1.get_start_date() - description = event1.get_description() - review_count = event1.get_review_count() - attendance_count = event1.get_attendance_count() - - # Assert - self.assertIn("3162700", rep) - self.assertIn("pylast.Event", rep) - self.assertEqual(text, "Event #3162700") - self.assertTrue(event1 != event2) - self.assertIn("Glastonbury", title) - found = False - for artist in artists: - if artist.name == "The Rolling Stones": - found = True - break - self.assertTrue(found) - self.assertIn("Wed, 26 Jun 2013", start) - self.assertIn("astonishing bundle", description) - self.assertGreater(review_count, 0) - self.assertGreater(attendance_count, 100) - def test_countries(self): # Arrange country1 = pylast.Country("Italy", self.network) @@ -1543,24 +1369,6 @@ class TestPyLast(unittest.TestCase): # Act / Assert self.assertTrue(album1 != album2) - def test_event_eq_none_is_false(self): - # Arrange - event1 = None - event_id = 3478520 # Glasto 2014 - event2 = pylast.Event(event_id, self.network) - - # Act / Assert - self.assertFalse(event1 == event2) - - def test_event_ne_none_is_true(self): - # Arrange - event1 = None - event_id = 3478520 # Glasto 2014 - event2 = pylast.Event(event_id, self.network) - - # Act / Assert - self.assertTrue(event1 != event2) - def test_band_members(self): # Arrange artist = pylast.Artist("The Beatles", self.network) From 27ba0dd6b3dbe79a2b77c8e4a0302c1767782100 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 26 Sep 2017 08:58:37 +0300 Subject: [PATCH 333/768] Remove dead Last.fm metro methods --- pylast/__init__.py | 210 ------------------------------------------- tests/test_pylast.py | 82 ----------------- 2 files changed, 292 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f31be7b..67e1533 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -230,13 +230,6 @@ class _Network(object): return Country(country_name, self) - def get_metro(self, metro_name, country_name): - """ - Returns a metro object - """ - - return Metro(metro_name, country_name, self) - def get_user(self, username): """ Returns a user object @@ -332,46 +325,6 @@ class _Network(object): return seq - def get_metro_weekly_chart_dates(self, cacheable=True): - """ - Returns a list of From and To tuples for the available metro charts. - """ - - doc = _Request(self, "geo.getMetroWeeklyChartlist").execute(cacheable) - - seq = [] - for node in doc.getElementsByTagName("chart"): - seq.append((node.getAttribute("from"), node.getAttribute("to"))) - - return seq - - def get_metros(self, country=None, cacheable=True): - """ - Get a list of valid countries and metros for use in the other - webservices. - Parameters: - country (Optional) : Optionally restrict the results to those Metros - from a particular country, as defined by the ISO 3166-1 country - names standard. - """ - params = {} - - if country: - params["country"] = country - - doc = _Request(self, "geo.getMetros", params).execute(cacheable) - - metros = doc.getElementsByTagName("metro") - seq = [] - - for metro in metros: - name = _extract(metro, "name") - country = _extract(metro, "country") - - seq.append(Metro(name, country, self)) - - return seq - def get_geo_top_artists(self, country, limit=None, cacheable=True): """Get the most popular artists on Last.fm by country. Parameters: @@ -1912,169 +1865,6 @@ class Country(_BaseObject): domain_name, "country") % {'country_name': country_name} -class Metro(_BaseObject): - """A metro at Last.fm.""" - - name = None - country = None - - __hash__ = _BaseObject.__hash__ - - def __init__(self, name, country, network): - _BaseObject.__init__(self, network, None) - - self.name = name - self.country = country - - def __repr__(self): - return "pylast.Metro(%s, %s, %s)" % ( - repr(self.name), repr(self.country), repr(self.network)) - - @_string_output - def __str__(self): - return self.get_name() + ", " + self.get_country() - - def __eq__(self, other): - return (self.get_name().lower() == other.get_name().lower() and - self.get_country().lower() == other.get_country().lower()) - - def __ne__(self, other): - return (self.get_name() != other.get_name() or - self.get_country().lower() != other.get_country().lower()) - - def _get_params(self): - return {'metro': self.get_name(), 'country': self.get_country()} - - def get_name(self): - """Returns the metro name.""" - - return self.name - - def get_country(self): - """Returns the metro country.""" - - return self.country - - def _get_chart( - self, method, tag="artist", limit=None, from_date=None, - to_date=None, cacheable=True): - """Internal helper for getting geo charts.""" - params = self._get_params() - if limit: - params["limit"] = limit - if from_date and to_date: - params["from"] = from_date - params["to"] = to_date - - doc = self._request(method, cacheable, params) - - seq = [] - for node in doc.getElementsByTagName(tag): - if tag == "artist": - item = Artist(_extract(node, "name"), self.network) - elif tag == "track": - title = _extract(node, "name") - artist = _extract_element_tree(node).get('artist')['name'] - item = Track(artist, title, self.network) - else: - return None - weight = _number(_extract(node, "listeners")) - seq.append(TopItem(item, weight)) - - return seq - - def get_artist_chart( - self, tag="artist", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of artists for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroArtistChart", tag=tag, limit=limit, - from_date=from_date, to_date=to_date, cacheable=cacheable) - - def get_hype_artist_chart( - self, tag="artist", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of hyped (up and coming) artists for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroHypeArtistChart", tag=tag, limit=limit, - from_date=from_date, to_date=to_date, cacheable=cacheable) - - def get_unique_artist_chart( - self, tag="artist", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of the artists which make that metro unique. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroUniqueArtistChart", tag=tag, limit=limit, - from_date=from_date, to_date=to_date, cacheable=cacheable) - - def get_track_chart( - self, tag="track", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of tracks for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroTrackChart", tag=tag, limit=limit, - from_date=from_date, to_date=to_date, cacheable=cacheable) - - def get_hype_track_chart( - self, tag="track", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of tracks for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroHypeTrackChart", tag=tag, - limit=limit, from_date=from_date, to_date=to_date, - cacheable=cacheable) - - def get_unique_track_chart( - self, tag="track", limit=None, from_date=None, to_date=None, - cacheable=True): - """Get a chart of tracks for a metro. - Parameters: - from_date (Optional) : Beginning timestamp of the weekly range - requested - to_date (Optional) : Ending timestamp of the weekly range requested - limit (Optional) : The number of results to fetch per page. - Defaults to 50. - """ - return self._get_chart( - "geo.getMetroUniqueTrackChart", tag=tag, limit=limit, - from_date=from_date, to_date=to_date, cacheable=cacheable) - - class Library(_BaseObject): """A user's Last.fm library.""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 21671ff..0281631 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -260,13 +260,6 @@ class TestPyLast(unittest.TestCase): # Act/Assert self.helper_is_thing_hashable(country) - def test_metro_is_hashable(self): - # Arrange - metro = self.network.get_metro("Helsinki", "Finland") - - # Act/Assert - self.helper_is_thing_hashable(metro) - def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -578,67 +571,6 @@ class TestPyLast(unittest.TestCase): (start, end) = dates[0] self.assertLess(start, end) - def test_get_metro_weekly_chart_dates(self): - # Arrange - # Act - dates = self.network.get_metro_weekly_chart_dates() - - # Assert - self.helper_dates_valid(dates) - - def helper_geo_chart(self, function_name, expected_type=pylast.Artist): - # Arrange - metro = self.network.get_metro("Madrid", "Spain") - dates = self.network.get_metro_weekly_chart_dates() - (from_date, to_date) = dates[0] - - # get metro.function_name() - func = getattr(metro, function_name, None) - - # Act - chart = func(from_date=from_date, to_date=to_date, limit=1) - - # Assert - self.assertEqual(len(chart), 1) - self.assertIsInstance(chart[0], pylast.TopItem) - self.assertIsInstance(chart[0].item, expected_type) - - def test_get_metro_artist_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart("get_artist_chart") - - def test_get_metro_hype_artist_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart("get_hype_artist_chart") - - def test_get_metro_unique_artist_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart("get_unique_artist_chart") - - def test_get_metro_track_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart("get_track_chart", expected_type=pylast.Track) - - def test_get_metro_hype_track_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart( - "get_hype_track_chart", expected_type=pylast.Track) - - def test_get_metro_unique_track_chart(self): - # Arrange/Act/Assert - self.helper_geo_chart( - "get_unique_track_chart", expected_type=pylast.Track) - - def test_geo_get_metros(self): - # Arrange - # Act - metros = self.network.get_metros(country="Poland") - - # Assert - self.assertGreaterEqual(len(metros), 1) - self.assertIsInstance(metros[0], pylast.Metro) - self.assertEqual(metros[0].get_country(), "Poland") - def test_geo_get_top_artists(self): # Arrange # Act @@ -661,20 +593,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(tracks[0], pylast.TopItem) self.assertIsInstance(tracks[0].item, pylast.Track) - def test_metro_class(self): - # Arrange - # Act - metro = self.network.get_metro("Bergen", "Norway") - - # Assert - self.assertEqual(metro.get_name(), "Bergen") - self.assertEqual(metro.get_country(), "Norway") - self.assertEqual(str(metro), "Bergen, Norway") - self.assertEqual(metro, pylast.Metro("Bergen", "Norway", self.network)) - self.assertNotEqual( - metro, - pylast.Metro("Wellington", "New Zealand", self.network)) - def helper_at_least_one_thing_in_top_list(self, things, expected_type): # Assert self.assertGreater(len(things), 1) From ec68660014d4ef9935f53a9b8331850d79b3e7bb Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 26 Sep 2017 09:08:40 +0300 Subject: [PATCH 334/768] Remove dead Last.fm chart test --- tests/test_pylast.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 0281631..ff92784 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -759,15 +759,6 @@ class TestPyLast(unittest.TestCase): self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) - def test_tag_charts(self): - # Arrange - tag = self.network.get_tag("rock") - dates = tag.get_weekly_chart_dates() - self.helper_dates_valid(dates) - - # Act/Assert - self.helper_get_assert_charts(tag, dates[-2]) - def test_user_charts(self): # Arrange lastfm_user = self.network.get_user("RJ") From 0eac6e9ae20127712cd0ba17080760c501b058ef Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 26 Sep 2017 09:14:24 +0300 Subject: [PATCH 335/768] Remove redundant functions --- pylast/__init__.py | 31 ------------------------------- tests/test_pylast.py | 9 --------- 2 files changed, 40 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 67e1533..333768b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2873,37 +2873,6 @@ def _extract(node, name, index=0): return None -def _extract_element_tree(node): - """Extract an element tree into a multi-level dictionary - - NB: If any elements have text nodes as well as nested - elements this will ignore the text nodes""" - - def _recurse_build_tree(rootNode, targetDict): - """Recursively build a multi-level dict""" - - def _has_child_elements(rootNode): - """Check if an element has any nested (child) elements""" - - for node in rootNode.childNodes: - if node.nodeType == node.ELEMENT_NODE: - return True - return False - - for node in rootNode.childNodes: - if node.nodeType == node.ELEMENT_NODE: - if _has_child_elements(node): - targetDict[node.tagName] = {} - _recurse_build_tree(node, targetDict[node.tagName]) - else: - val = None if node.firstChild is None else \ - _unescape_htmlentity(node.firstChild.data.strip()) - targetDict[node.tagName] = val - return targetDict - - return _recurse_build_tree(node, {}) - - def _extract_all(node, name, limit_count=None): """Extracts all the values from the xml string. returning a list.""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index ff92784..91c51d6 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -624,15 +624,6 @@ class TestPyLast(unittest.TestCase): self.assertIsInstance(thing2.item, expected_type) self.assertNotEqual(thing1, thing2) - def helper_two_things_in_list(self, things, expected_type): - # Assert - self.assertEqual(len(things), 2) - self.assertIsInstance(things, list) - thing1 = things[0] - thing2 = things[1] - self.assertIsInstance(thing1, expected_type) - self.assertIsInstance(thing2, expected_type) - def test_user_get_top_tags_with_limit(self): # Arrange user = self.network.get_user("RJ") From a8522fded3c6a6904b9b9c93e6578c3751229a38 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 26 Sep 2017 11:04:02 +0300 Subject: [PATCH 336/768] Bring back test_caching on another method --- tests/test_pylast.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 91c51d6..6b069b8 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -941,6 +941,21 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsInstance(library, pylast.Library) + def test_caching(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + self.network.enable_caching() + tags1 = user.get_top_tags(limit=1, cacheable=True) + tags2 = user.get_top_tags(limit=1, cacheable=True) + + # Assert + self.assertTrue(self.network.is_caching_enabled()) + self.assertEqual(tags1, tags2) + self.network.disable_caching() + self.assertFalse(self.network.is_caching_enabled()) + def test_album_mbid(self): # Arrange mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" From 25f419204a526db9ca8493ad4c68599d2ae21c07 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 26 Sep 2017 18:01:17 +0300 Subject: [PATCH 337/768] Start tests refactor --- tests/test_pylast.py | 135 ++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 60 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 6b069b8..3d8ad83 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -30,7 +30,7 @@ def load_secrets(): @flaky(max_runs=5, min_passes=1) -class TestPyLast(unittest.TestCase): +class PyLastTestCase(unittest.TestCase): secrets = None @@ -56,6 +56,23 @@ class TestPyLast(unittest.TestCase): if value is None or len(value) == 0: pytest.skip("Last.fm API is broken.") + +class TestPyLastAlbum(PyLastTestCase): + + def test_album_tags_are_topitems(self): + # Arrange + albums = self.network.get_user('RJ').get_top_albums() + + # Act + tags = albums[0].item.get_top_tags(limit=1) + + # Assert + self.assertGreater(len(tags), 0) + self.assertIsInstance(tags[0], pylast.TopItem) + + +class TestPyLastNetwork(PyLastTestCase): + def test_scrobble(self): # Arrange artist = "Test Artist" @@ -73,6 +90,62 @@ class TestPyLast(unittest.TestCase): self.assertEqual(str(last_scrobble.track.title), str(title)) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + def test_update_now_playing(self): + # Arrange + artist = "Test Artist" + title = "test title" + album = "Test Album" + track_number = 1 + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.update_now_playing( + artist=artist, title=title, album=album, track_number=track_number) + + # Assert + current_track = lastfm_user.get_now_playing() + self.assertIsNotNone(current_track) + self.assertEqual(str(current_track.title), "test title") + self.assertEqual(str(current_track.artist), "Test Artist") + + +class TestPyLastTrack(PyLastTestCase): + + def test_love(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = self.network.get_track(artist, title) + lastfm_user = self.network.get_user(self.username) + + # Act + track.love() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + self.assertEqual(str(loved[0].track.artist), "Test Artist") + self.assertEqual(str(loved[0].track.title), "test title") + + def test_unlove(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + title = "test title" + track = pylast.Track(artist, title, self.network) + lastfm_user = self.network.get_user(self.username) + track.love() + + # Act + track.unlove() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + if len(loved): # OK to be empty but if not: + self.assertNotEqual(str(loved.track.artist), "Test Artist") + self.assertNotEqual(str(loved.track.title), "test title") + + +class TestPyLastUser(PyLastTestCase): + def test_get_user_registration(self): # Arrange username = "RJ" @@ -123,38 +196,6 @@ class TestPyLast(unittest.TestCase): # Assert self.assertIsNone(country) - def test_love(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = self.network.get_track(artist, title) - lastfm_user = self.network.get_user(self.username) - - # Act - track.love() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - self.assertEqual(str(loved[0].track.artist), "Test Artist") - self.assertEqual(str(loved[0].track.title), "test title") - - def test_unlove(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - title = "test title" - track = pylast.Track(artist, title, self.network) - lastfm_user = self.network.get_user(self.username) - track.love() - - # Act - track.unlove() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - if len(loved): # OK to be empty but if not: - self.assertNotEqual(str(loved.track.artist), "Test Artist") - self.assertNotEqual(str(loved.track.title), "test title") - def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -197,34 +238,8 @@ class TestPyLast(unittest.TestCase): self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) - def test_update_now_playing(self): - # Arrange - artist = "Test Artist" - title = "test title" - album = "Test Album" - track_number = 1 - lastfm_user = self.network.get_user(self.username) - # Act - self.network.update_now_playing( - artist=artist, title=title, album=album, track_number=track_number) - - # Assert - current_track = lastfm_user.get_now_playing() - self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title), "test title") - self.assertEqual(str(current_track.artist), "Test Artist") - - def test_album_tags_are_topitems(self): - # Arrange - albums = self.network.get_user('RJ').get_top_albums() - - # Act - tags = albums[0].item.get_top_tags(limit=1) - - # Assert - self.assertGreater(len(tags), 0) - self.assertIsInstance(tags[0], pylast.TopItem) +class TestPyLast(PyLastTestCase): def helper_is_thing_hashable(self, thing): # Arrange From 31aeb6e69a8f2f416dd8dcad50ccecdaf9b846ad Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 17 Oct 2017 21:45:20 +0300 Subject: [PATCH 338/768] Continue tests refactor --- tests/test_pylast.py | 2011 +++++++++++++++++++++--------------------- 1 file changed, 1010 insertions(+), 1001 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 3d8ad83..2342e02 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -56,6 +56,72 @@ class PyLastTestCase(unittest.TestCase): if value is None or len(value) == 0: pytest.skip("Last.fm API is broken.") + def helper_is_thing_hashable(self, thing): + # Arrange + things = set() + + # Act + things.add(thing) + + # Assert + self.assertIsNotNone(thing) + self.assertEqual(len(things), 1) + + def helper_validate_results(self, a, b, c): + # Assert + self.assertIsNotNone(a) + self.assertIsNotNone(b) + self.assertIsNotNone(c) + self.assertGreaterEqual(len(a), 0) + self.assertGreaterEqual(len(b), 0) + self.assertGreaterEqual(len(c), 0) + self.assertEqual(a, b) + self.assertEqual(b, c) + + def helper_validate_cacheable(self, thing, function_name): + # Arrange + # get thing.function_name() + func = getattr(thing, function_name, None) + + # Act + result1 = func(limit=1, cacheable=False) + result2 = func(limit=1, cacheable=True) + result3 = func(limit=1) + + # Assert + self.helper_validate_results(result1, result2, result3) + + def helper_at_least_one_thing_in_top_list(self, things, expected_type): + # Assert + self.assertGreater(len(things), 1) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], pylast.TopItem) + self.assertIsInstance(things[0].item, expected_type) + + def helper_only_one_thing_in_top_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 1) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], pylast.TopItem) + self.assertIsInstance(things[0].item, expected_type) + + def helper_only_one_thing_in_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 1) + self.assertIsInstance(things, list) + self.assertIsInstance(things[0], expected_type) + + def helper_two_different_things_in_top_list(self, things, expected_type): + # Assert + self.assertEqual(len(things), 2) + thing1 = things[0] + thing2 = things[1] + self.assertIsInstance(thing1, pylast.TopItem) + self.assertIsInstance(thing2, pylast.TopItem) + self.assertIsInstance(thing1.item, expected_type) + self.assertIsInstance(thing2.item, expected_type) + self.assertNotEqual(thing1, thing2) + class TestPyLastAlbum(PyLastTestCase): @@ -70,188 +136,6 @@ class TestPyLastAlbum(PyLastTestCase): self.assertGreater(len(tags), 0) self.assertIsInstance(tags[0], pylast.TopItem) - -class TestPyLastNetwork(PyLastTestCase): - - def test_scrobble(self): - # Arrange - artist = "Test Artist" - title = "test title" - timestamp = self.unix_timestamp() - lastfm_user = self.network.get_user(self.username) - - # Act - self.network.scrobble(artist=artist, title=title, timestamp=timestamp) - - # Assert - # limit=2 to ignore now-playing: - last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] - self.assertEqual(str(last_scrobble.track.artist), str(artist)) - self.assertEqual(str(last_scrobble.track.title), str(title)) - self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - - def test_update_now_playing(self): - # Arrange - artist = "Test Artist" - title = "test title" - album = "Test Album" - track_number = 1 - lastfm_user = self.network.get_user(self.username) - - # Act - self.network.update_now_playing( - artist=artist, title=title, album=album, track_number=track_number) - - # Assert - current_track = lastfm_user.get_now_playing() - self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title), "test title") - self.assertEqual(str(current_track.artist), "Test Artist") - - -class TestPyLastTrack(PyLastTestCase): - - def test_love(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = self.network.get_track(artist, title) - lastfm_user = self.network.get_user(self.username) - - # Act - track.love() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - self.assertEqual(str(loved[0].track.artist), "Test Artist") - self.assertEqual(str(loved[0].track.title), "test title") - - def test_unlove(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - title = "test title" - track = pylast.Track(artist, title, self.network) - lastfm_user = self.network.get_user(self.username) - track.love() - - # Act - track.unlove() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - if len(loved): # OK to be empty but if not: - self.assertNotEqual(str(loved.track.artist), "Test Artist") - self.assertNotEqual(str(loved.track.title), "test title") - - -class TestPyLastUser(PyLastTestCase): - - def test_get_user_registration(self): - # Arrange - username = "RJ" - user = self.network.get_user(username) - - # Act - registered = user.get_registered() - - # Assert - # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp - if int(registered): - pytest.skip("Last.fm API is broken.") - - # Just check date because of timezones - self.assertIn(u"2002-11-20 ", registered) - - def test_get_user_unixtime_registration(self): - # Arrange - username = "RJ" - user = self.network.get_user(username) - - # Act - unixtime_registered = user.get_unixtime_registered() - - # Assert - # Just check date because of timezones - self.assertEqual(unixtime_registered, u"1037793040") - - def test_get_genderless_user(self): - # Arrange - # Currently test_user has no gender set: - lastfm_user = self.network.get_user("test_user") - - # Act - gender = lastfm_user.get_gender() - - # Assert - self.assertIsNone(gender) - - def test_get_countryless_user(self): - # Arrange - # Currently test_user has no country set: - lastfm_user = self.network.get_user("test_user") - - # Act - country = lastfm_user.get_country() - - # Assert - self.assertIsNone(country) - - def test_user_equals_none(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - value = (lastfm_user is None) - - # Assert - self.assertFalse(value) - - def test_user_not_equal_to_none(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - value = (lastfm_user is not None) - - # Assert - self.assertTrue(value) - - def test_now_playing_user_with_no_scrobbles(self): - # Arrange - # Currently test-account has no scrobbles: - user = self.network.get_user('test-account') - - # Act - current_track = user.get_now_playing() - - # Assert - self.assertIsNone(current_track) - - def test_love_limits(self): - # Arrange - # Currently test-account has at least 23 loved tracks: - user = self.network.get_user("test-user") - - # Act/Assert - self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) - self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) - - -class TestPyLast(PyLastTestCase): - - def helper_is_thing_hashable(self, thing): - # Arrange - things = set() - - # Act - things.add(thing) - - # Assert - self.assertIsNotNone(thing) - self.assertEqual(len(things), 1) - def test_album_is_hashable(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") @@ -259,96 +143,6 @@ class TestPyLast(PyLastTestCase): # Act/Assert self.helper_is_thing_hashable(album) - def test_artist_is_hashable(self): - # Arrange - test_artist = self.network.get_artist("Test Artist") - artist = test_artist.get_similar(limit=2)[0].item - self.assertIsInstance(artist, pylast.Artist) - - # Act/Assert - self.helper_is_thing_hashable(artist) - - def test_country_is_hashable(self): - # Arrange - country = self.network.get_country("Italy") - - # Act/Assert - self.helper_is_thing_hashable(country) - - def test_library_is_hashable(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - - # Act/Assert - self.helper_is_thing_hashable(library) - - def test_tag_is_hashable(self): - # Arrange - tag = self.network.get_top_tags(limit=1)[0] - - # Act/Assert - self.helper_is_thing_hashable(tag) - - def test_track_is_hashable(self): - # Arrange - artist = self.network.get_artist("Test Artist") - track = artist.get_top_tracks()[0].item - self.assertIsInstance(track, pylast.Track) - - # Act/Assert - self.helper_is_thing_hashable(track) - - def test_user_is_hashable(self): - # Arrange - user = self.network.get_user(self.username) - - # Act/Assert - self.helper_is_thing_hashable(user) - - def test_invalid_xml(self): - # Arrange - # Currently causes PCDATA invalid Char value 25 - artist = "Blind Willie Johnson" - title = "It's nobody's fault but mine" - - # Act - search = self.network.search_for_track(artist, title) - total = search.get_total_result_count() - - # Assert - self.skip_if_lastfm_api_broken(total) - self.assertGreaterEqual(int(total), 0) - - def test_user_play_count_in_track_info(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) - - # Act - count = track.get_userplaycount() - - # Assert - self.assertGreaterEqual(count, 0) - - def test_user_loved_in_track_info(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) - - # Act - loved = track.get_userloved() - - # Assert - self.assertIsNotNone(loved) - self.assertIsInstance(loved, bool) - self.assertNotIsInstance(loved, str) - def test_album_in_recent_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) @@ -370,101 +164,6 @@ class TestPyLast(PyLastTestCase): # Assert self.assertTrue(hasattr(track, 'album')) - def test_enable_rate_limiting(self): - # Arrange - self.assertFalse(self.network.is_rate_limited()) - - # Act - self.network.enable_rate_limit() - then = time.time() - # Make some network call, limit not applied first time - self.network.get_user(self.username) - # Make a second network call, limiting should be applied - self.network.get_top_artists() - now = time.time() - - # Assert - self.assertTrue(self.network.is_rate_limited()) - self.assertGreaterEqual(now - then, 0.2) - - def test_disable_rate_limiting(self): - # Arrange - self.network.enable_rate_limit() - self.assertTrue(self.network.is_rate_limited()) - - # Act - self.network.disable_rate_limit() - # Make some network call, limit not applied first time - self.network.get_user(self.username) - # Make a second network call, limiting should be applied - self.network.get_top_artists() - - # Assert - self.assertFalse(self.network.is_rate_limited()) - - # Commented out because (a) it'll take a long time and (b) it strangely - # fails due Last.fm's complaining of hitting the rate limit, even when - # limited to one call per second. The ToS allows 5 calls per second. - # def test_get_all_scrobbles(self): - # # Arrange - # lastfm_user = self.network.get_user("RJ") - # self.network.enable_rate_limit() # this is going to be slow... - - # # Act - # tracks = lastfm_user.get_recent_tracks(limit=None) - - # # Assert - # self.assertGreaterEqual(len(tracks), 0) - - def test_pickle(self): - # Arrange - import pickle - lastfm_user = self.network.get_user(self.username) - filename = str(self.unix_timestamp()) + ".pkl" - - # Act - with open(filename, "wb") as f: - pickle.dump(lastfm_user, f) - with open(filename, "rb") as f: - loaded_user = pickle.load(f) - os.remove(filename) - - # Assert - self.assertEqual(lastfm_user, loaded_user) - - def test_bio_published_date(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_published_date() - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - - def test_bio_content(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_content(language="en") - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - - def test_bio_summary(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_summary(language="en") - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - def test_album_wiki_content(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -498,189 +197,66 @@ class TestPyLast(PyLastTestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) - def test_track_wiki_content(self): + def test_album_eq_none_is_false(self): # Arrange - track = pylast.Track("Test Artist", "test title", self.network) + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) - # Act - wiki = track.get_wiki_content() + # Act / Assert + self.assertFalse(album1 == album2) - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - def test_track_wiki_summary(self): + def test_album_ne_none_is_true(self): # Arrange - track = pylast.Track("Test Artist", "test title", self.network) + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) - # Act - wiki = track.get_wiki_summary() + # Act / Assert + self.assertTrue(album1 != album2) - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - def test_lastfm_network_name(self): - # Act - name = str(self.network) +class TestPyLastArtist(PyLastTestCase): - # Assert - self.assertEqual(name, "Last.fm Network") - - def helper_validate_results(self, a, b, c): - # Assert - self.assertIsNotNone(a) - self.assertIsNotNone(b) - self.assertIsNotNone(c) - self.assertGreaterEqual(len(a), 0) - self.assertGreaterEqual(len(b), 0) - self.assertGreaterEqual(len(c), 0) - self.assertEqual(a, b) - self.assertEqual(b, c) - - def helper_validate_cacheable(self, thing, function_name): + def test_artist_is_hashable(self): # Arrange - # get thing.function_name() - func = getattr(thing, function_name, None) - - # Act - result1 = func(limit=1, cacheable=False) - result2 = func(limit=1, cacheable=True) - result3 = func(limit=1) - - # Assert - self.helper_validate_results(result1, result2, result3) - - def test_cacheable_library(self): - # Arrange - library = pylast.Library(self.username, self.network) + test_artist = self.network.get_artist("Test Artist") + artist = test_artist.get_similar(limit=2)[0].item + self.assertIsInstance(artist, pylast.Artist) # Act/Assert - self.helper_validate_cacheable(library, "get_artists") + self.helper_is_thing_hashable(artist) - def test_cacheable_user_artist_tracks(self): + def test_bio_published_date(self): # Arrange - lastfm_user = self.network.get_authenticated_user() + artist = pylast.Artist("Test Artist", self.network) # Act - result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) - result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) - result3 = lastfm_user.get_artist_tracks("Test Artist") + bio = artist.get_bio_published_date() # Assert - self.helper_validate_results(result1, result2, result3) + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) - def test_cacheable_user(self): + def test_bio_content(self): # Arrange - lastfm_user = self.network.get_authenticated_user() - - # Act/Assert - self.helper_validate_cacheable(lastfm_user, "get_friends") - self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") - self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") - - def helper_dates_valid(self, dates): - # Assert - self.assertGreaterEqual(len(dates), 1) - self.assertIsInstance(dates[0], tuple) - (start, end) = dates[0] - self.assertLess(start, end) - - def test_geo_get_top_artists(self): - # Arrange - # Act - artists = self.network.get_geo_top_artists( - country="United Kingdom", limit=1) - - # Assert - self.assertEqual(len(artists), 1) - self.assertIsInstance(artists[0], pylast.TopItem) - self.assertIsInstance(artists[0].item, pylast.Artist) - - def test_geo_get_top_tracks(self): - # Arrange - # Act - tracks = self.network.get_geo_top_tracks( - country="United Kingdom", location="Manchester", limit=1) - - # Assert - self.assertEqual(len(tracks), 1) - self.assertIsInstance(tracks[0], pylast.TopItem) - self.assertIsInstance(tracks[0].item, pylast.Track) - - def helper_at_least_one_thing_in_top_list(self, things, expected_type): - # Assert - self.assertGreater(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], pylast.TopItem) - self.assertIsInstance(things[0].item, expected_type) - - def helper_only_one_thing_in_top_list(self, things, expected_type): - # Assert - self.assertEqual(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], pylast.TopItem) - self.assertIsInstance(things[0].item, expected_type) - - def helper_only_one_thing_in_list(self, things, expected_type): - # Assert - self.assertEqual(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], expected_type) - - def helper_two_different_things_in_top_list(self, things, expected_type): - # Assert - self.assertEqual(len(things), 2) - thing1 = things[0] - thing2 = things[1] - self.assertIsInstance(thing1, pylast.TopItem) - self.assertIsInstance(thing2, pylast.TopItem) - self.assertIsInstance(thing1.item, expected_type) - self.assertIsInstance(thing2.item, expected_type) - self.assertNotEqual(thing1, thing2) - - def test_user_get_top_tags_with_limit(self): - # Arrange - user = self.network.get_user("RJ") + artist = pylast.Artist("Test Artist", self.network) # Act - tags = user.get_top_tags(limit=1) + bio = artist.get_bio_content(language="en") # Assert - self.skip_if_lastfm_api_broken(tags) - self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) - def test_network_get_top_artists_with_limit(self): + def test_bio_summary(self): # Arrange + artist = pylast.Artist("Test Artist", self.network) + # Act - artists = self.network.get_top_artists(limit=1) + bio = artist.get_bio_summary(language="en") # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_network_get_top_tags_with_limit(self): - # Arrange - # Act - tags = self.network.get_top_tags(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - - def test_network_get_top_tags_with_no_limit(self): - # Arrange - # Act - tags = self.network.get_top_tags() - - # Assert - self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) - - def test_network_get_top_tracks_with_limit(self): - # Arrange - # Act - tracks = self.network.get_top_tracks(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) def test_artist_top_tracks(self): # Arrange @@ -704,310 +280,6 @@ class TestPyLast(PyLastTestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) - def test_country_top_tracks(self): - # Arrange - country = self.network.get_country("Croatia") - - # Act - things = country.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_country_network_top_tracks(self): - # Arrange - # Act - things = self.network.get_geo_top_tracks("Croatia", limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_tag_top_tracks(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - things = tag.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_user_top_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - things = lastfm_user.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def helper_assert_chart(self, chart, expected_type): - # Assert - self.assertIsNotNone(chart) - self.assertGreater(len(chart), 0) - self.assertIsInstance(chart[0], pylast.TopItem) - self.assertIsInstance(chart[0].item, expected_type) - - def helper_get_assert_charts(self, thing, date): - # Arrange - (from_date, to_date) = date - - # Act - artist_chart = thing.get_weekly_artist_charts(from_date, to_date) - if type(thing) is not pylast.Tag: - album_chart = thing.get_weekly_album_charts(from_date, to_date) - track_chart = thing.get_weekly_track_charts(from_date, to_date) - - # Assert - self.helper_assert_chart(artist_chart, pylast.Artist) - if type(thing) is not pylast.Tag: - self.helper_assert_chart(album_chart, pylast.Album) - self.helper_assert_chart(track_chart, pylast.Track) - - def test_user_charts(self): - # Arrange - lastfm_user = self.network.get_user("RJ") - dates = lastfm_user.get_weekly_chart_dates() - self.helper_dates_valid(dates) - - # Act/Assert - self.helper_get_assert_charts(lastfm_user, dates[0]) - - # Commented out to avoid spamming - # def test_share_spam(self): - # # Arrange - # users_to_spam = [TODO_ENTER_SPAMEES_HERE] - # spam_message = "Dig the krazee sound!" - # artist = self.network.get_top_artists(limit=1)[0].item - # track = artist.get_top_tracks(limit=1)[0].item - - # # Act - # artist.share(users_to_spam, spam_message) - # track.share(users_to_spam, spam_message) - - # Assert - # Check inbox for spam! - - # album/artist/track/user - - def test_album_data(self): - # Arrange - thing = self.network.get_album("Test Artist", "Test Album") - - # Act - stringed = str(thing) - repr = thing.__repr__() - title = thing.get_title() - name = thing.get_name() - playcount = thing.get_playcount() - url = thing.get_url() - - # Assert - self.assertEqual(stringed, "Test Artist - Test Album") - self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/music/test%2bartist/test%2balbum", url) - - def test_track_data(self): - # Arrange - thing = self.network.get_track("Test Artist", "test title") - - # Act - stringed = str(thing) - repr = thing.__repr__() - title = thing.get_title() - name = thing.get_name() - playcount = thing.get_playcount() - url = thing.get_url(pylast.DOMAIN_FRENCH) - - # Assert - self.assertEqual(stringed, "Test Artist - test title") - self.assertIn("pylast.Track('Test Artist', 'test title',", repr) - self.assertEqual(title, "test title") - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) - - def test_tag_top_artists(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - artists = tag.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_country_top_artists(self): - # Arrange - country = self.network.get_country("Ukraine") - - # Act - artists = country.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_user_top_artists(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - artists = lastfm_user.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_tag_top_albums(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - albums = tag.get_top_albums(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(albums, pylast.Album) - - def test_user_top_albums(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - albums = user.get_top_albums(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(albums, pylast.Album) - - def test_user_tagged_artists(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["artisttagola"] - artist = self.network.get_artist("Test Artist") - artist.add_tags(tags) - - # Act - artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(artists, pylast.Artist) - - def test_user_tagged_albums(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["albumtagola"] - album = self.network.get_album("Test Artist", "Test Album") - album.add_tags(tags) - - # Act - albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(albums, pylast.Album) - - def test_user_tagged_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["tracktagola"] - track = self.network.get_track("Test Artist", "test title") - track.add_tags(tags) - - # Act - tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(tracks, pylast.Track) - - def test_user_subscriber(self): - # Arrange - subscriber = self.network.get_user("RJ") - non_subscriber = self.network.get_user("Test User") - - # Act - subscriber_is_subscriber = subscriber.is_subscriber() - non_subscriber_is_subscriber = non_subscriber.is_subscriber() - - # Assert - self.assertTrue(subscriber_is_subscriber) - self.assertFalse(non_subscriber_is_subscriber) - - def test_user_get_image(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - url = user.get_image() - - # Assert - self.assertTrue(url.startswith("https://")) - - def test_user_get_library(self): - # Arrange - user = self.network.get_user(self.username) - - # Act - library = user.get_library() - - # Assert - self.assertIsInstance(library, pylast.Library) - - def test_caching(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - self.network.enable_caching() - tags1 = user.get_top_tags(limit=1, cacheable=True) - tags2 = user.get_top_tags(limit=1, cacheable=True) - - # Assert - self.assertTrue(self.network.is_caching_enabled()) - self.assertEqual(tags1, tags2) - self.network.disable_caching() - self.assertFalse(self.network.is_caching_enabled()) - - def test_album_mbid(self): - # Arrange - mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" - - # Act - album = self.network.get_album_by_mbid(mbid) - album_mbid = album.get_mbid() - - # Assert - self.assertIsInstance(album, pylast.Album) - self.assertEqual(album.title.lower(), "test") - self.assertEqual(album_mbid, mbid) - - def test_artist_mbid(self): - # Arrange - mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" - - # Act - artist = self.network.get_artist_by_mbid(mbid) - - # Assert - self.assertIsInstance(artist, pylast.Artist) - self.assertEqual(artist.name, "MusicBrainz Test Artist") - - def test_track_mbid(self): - # Arrange - mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" - - # Act - track = self.network.get_track_by_mbid(mbid) - track_mbid = track.get_mbid() - - # Assert - self.assertIsInstance(track, pylast.Track) - self.assertEqual(track.title, "first") - self.assertEqual(track_mbid, mbid) - def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -1120,6 +392,918 @@ class TestPyLast(PyLastTestCase): self.assertTrue(found1) self.assertTrue(found2) + def test_artists(self): + # Arrange + artist1 = self.network.get_artist("Radiohead") + artist2 = self.network.get_artist("Portishead") + + # Act + url = artist1.get_url() + mbid = artist1.get_mbid() + image = artist1.get_cover_image() + playcount = artist1.get_playcount() + streamable = artist1.is_streamable() + name = artist1.get_name(properly_capitalized=False) + name_cap = artist1.get_name(properly_capitalized=True) + + # Assert + self.assertIn("http", image) + self.assertGreater(playcount, 1) + self.assertTrue(artist1 != artist2) + self.assertEqual(name.lower(), name_cap.lower()) + self.assertEqual(url, "https://www.last.fm/music/radiohead") + self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") + self.assertIsInstance(streamable, bool) + + def test_artist_eq_none_is_false(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertFalse(artist1 == artist2) + + def test_artist_ne_none_is_true(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertTrue(artist1 != artist2) + + def test_band_members(self): + # Arrange + artist = pylast.Artist("The Beatles", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.skip_if_lastfm_api_broken(band_members) + self.assertGreaterEqual(len(band_members), 4) + + def test_no_band_members(self): + # Arrange + artist = pylast.Artist("John Lennon", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.assertIsNone(band_members) + + def test_artist_get_correction(self): + # Arrange + artist = pylast.Artist("guns and roses", self.network) + + # Act + corrected_artist_name = artist.get_correction() + + # Assert + self.assertEqual(corrected_artist_name, "Guns N' Roses") + + +class TestPyLastCountry(PyLastTestCase): + + def test_country_is_hashable(self): + # Arrange + country = self.network.get_country("Italy") + + # Act/Assert + self.helper_is_thing_hashable(country) + + def test_countries(self): + # Arrange + country1 = pylast.Country("Italy", self.network) + country2 = pylast.Country("Finland", self.network) + + # Act + text = str(country1) + rep = repr(country1) + url = country1.get_url() + + # Assert + self.assertIn("Italy", rep) + self.assertIn("pylast.Country", rep) + self.assertEqual(text, "Italy") + self.assertTrue(country1 == country1) + self.assertTrue(country1 != country2) + self.assertEqual(url, "https://www.last.fm/place/italy") + + +class TestPyLastLibrary(PyLastTestCase): + + def test_library_is_hashable(self): + # Arrange + library = pylast.Library(user=self.username, network=self.network) + + # Act/Assert + self.helper_is_thing_hashable(library) + + def test_cacheable_library(self): + # Arrange + library = pylast.Library(self.username, self.network) + + # Act/Assert + self.helper_validate_cacheable(library, "get_artists") + + +class TestPyLastNetwork(PyLastTestCase): + + def test_scrobble(self): + # Arrange + artist = "Test Artist" + title = "test title" + timestamp = self.unix_timestamp() + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.scrobble(artist=artist, title=title, timestamp=timestamp) + + # Assert + # limit=2 to ignore now-playing: + last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] + self.assertEqual(str(last_scrobble.track.artist), str(artist)) + self.assertEqual(str(last_scrobble.track.title), str(title)) + self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + + def test_update_now_playing(self): + # Arrange + artist = "Test Artist" + title = "test title" + album = "Test Album" + track_number = 1 + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.update_now_playing( + artist=artist, title=title, album=album, track_number=track_number) + + # Assert + current_track = lastfm_user.get_now_playing() + self.assertIsNotNone(current_track) + self.assertEqual(str(current_track.title), "test title") + self.assertEqual(str(current_track.artist), "Test Artist") + + def test_invalid_xml(self): + # Arrange + # Currently causes PCDATA invalid Char value 25 + artist = "Blind Willie Johnson" + title = "It's nobody's fault but mine" + + # Act + search = self.network.search_for_track(artist, title) + total = search.get_total_result_count() + + # Assert + self.skip_if_lastfm_api_broken(total) + self.assertGreaterEqual(int(total), 0) + + def test_enable_rate_limiting(self): + # Arrange + self.assertFalse(self.network.is_rate_limited()) + + # Act + self.network.enable_rate_limit() + then = time.time() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + now = time.time() + + # Assert + self.assertTrue(self.network.is_rate_limited()) + self.assertGreaterEqual(now - then, 0.2) + + def test_disable_rate_limiting(self): + # Arrange + self.network.enable_rate_limit() + self.assertTrue(self.network.is_rate_limited()) + + # Act + self.network.disable_rate_limit() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + + # Assert + self.assertFalse(self.network.is_rate_limited()) + + def test_lastfm_network_name(self): + # Act + name = str(self.network) + + # Assert + self.assertEqual(name, "Last.fm Network") + + def test_geo_get_top_artists(self): + # Arrange + # Act + artists = self.network.get_geo_top_artists( + country="United Kingdom", limit=1) + + # Assert + self.assertEqual(len(artists), 1) + self.assertIsInstance(artists[0], pylast.TopItem) + self.assertIsInstance(artists[0].item, pylast.Artist) + + def test_geo_get_top_tracks(self): + # Arrange + # Act + tracks = self.network.get_geo_top_tracks( + country="United Kingdom", location="Manchester", limit=1) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertIsInstance(tracks[0], pylast.TopItem) + self.assertIsInstance(tracks[0].item, pylast.Track) + + def test_network_get_top_artists_with_limit(self): + # Arrange + # Act + artists = self.network.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_network_get_top_tags_with_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + def test_network_get_top_tags_with_no_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags() + + # Assert + self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) + + def test_network_get_top_tracks_with_limit(self): + # Arrange + # Act + tracks = self.network.get_top_tracks(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + + def test_country_top_tracks(self): + # Arrange + country = self.network.get_country("Croatia") + + # Act + things = country.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_country_network_top_tracks(self): + # Arrange + # Act + things = self.network.get_geo_top_tracks("Croatia", limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_tag_top_tracks(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + things = tag.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_album_data(self): + # Arrange + thing = self.network.get_album("Test Artist", "Test Album") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url() + + # Assert + self.assertEqual(stringed, "Test Artist - Test Album") + self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "https://www.last.fm/music/test%2bartist/test%2balbum", url) + + def test_track_data(self): + # Arrange + thing = self.network.get_track("Test Artist", "test title") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url(pylast.DOMAIN_FRENCH) + + # Assert + self.assertEqual(stringed, "Test Artist - test title") + self.assertIn("pylast.Track('Test Artist', 'test title',", repr) + self.assertEqual(title, "test title") + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) + + def test_country_top_artists(self): + # Arrange + country = self.network.get_country("Ukraine") + + # Act + artists = country.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_caching(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + self.network.enable_caching() + tags1 = user.get_top_tags(limit=1, cacheable=True) + tags2 = user.get_top_tags(limit=1, cacheable=True) + + # Assert + self.assertTrue(self.network.is_caching_enabled()) + self.assertEqual(tags1, tags2) + self.network.disable_caching() + self.assertFalse(self.network.is_caching_enabled()) + + def test_album_mbid(self): + # Arrange + mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" + + # Act + album = self.network.get_album_by_mbid(mbid) + album_mbid = album.get_mbid() + + # Assert + self.assertIsInstance(album, pylast.Album) + self.assertEqual(album.title.lower(), "test") + self.assertEqual(album_mbid, mbid) + + def test_artist_mbid(self): + # Arrange + mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" + + # Act + artist = self.network.get_artist_by_mbid(mbid) + + # Assert + self.assertIsInstance(artist, pylast.Artist) + self.assertEqual(artist.name, "MusicBrainz Test Artist") + + def test_track_mbid(self): + # Arrange + mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" + + # Act + track = self.network.get_track_by_mbid(mbid) + track_mbid = track.get_mbid() + + # Assert + self.assertIsInstance(track, pylast.Track) + self.assertEqual(track.title, "first") + self.assertEqual(track_mbid, mbid) + + def test_init_with_token(self): + # Arrange/Act + try: + pylast.LastFMNetwork( + api_key=self.__class__.secrets["api_key"], + api_secret=self.__class__.secrets["api_secret"], + token="invalid", + ) + except pylast.WSError as exc: + msg = str(exc) + + # Assert + self.assertEqual(msg, + "Unauthorized Token - This token has not been issued") + + +class TestPyLastTag(PyLastTestCase): + + def test_tag_is_hashable(self): + # Arrange + tag = self.network.get_top_tags(limit=1)[0] + + # Act/Assert + self.helper_is_thing_hashable(tag) + + def test_tag_top_artists(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + artists = tag.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_tag_top_albums(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + albums = tag.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_tags(self): + # Arrange + tag1 = self.network.get_tag("blues") + tag2 = self.network.get_tag("rock") + + # Act + tag_repr = repr(tag1) + tag_str = str(tag1) + name = tag1.get_name(properly_capitalized=True) + url = tag1.get_url() + + # Assert + self.assertEqual("blues", tag_str) + self.assertIn("pylast.Tag", tag_repr) + self.assertIn("blues", tag_repr) + self.assertEqual("blues", name) + self.assertTrue(tag1 == tag1) + self.assertTrue(tag1 != tag2) + self.assertEqual(url, "https://www.last.fm/tag/blues") + + def test_tags_similar(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + similar = tag.get_similar() + + # Assert + self.skip_if_lastfm_api_broken(similar) + found = False + for tag in similar: + if tag.name == "delta blues": + found = True + break + self.assertTrue(found) + + +class TestPyLastTrack(PyLastTestCase): + + def test_love(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = self.network.get_track(artist, title) + lastfm_user = self.network.get_user(self.username) + + # Act + track.love() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + self.assertEqual(str(loved[0].track.artist), "Test Artist") + self.assertEqual(str(loved[0].track.title), "test title") + + def test_unlove(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + title = "test title" + track = pylast.Track(artist, title, self.network) + lastfm_user = self.network.get_user(self.username) + track.love() + + # Act + track.unlove() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + if len(loved): # OK to be empty but if not: + self.assertNotEqual(str(loved.track.artist), "Test Artist") + self.assertNotEqual(str(loved.track.title), "test title") + + def test_user_play_count_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) + + # Act + count = track.get_userplaycount() + + # Assert + self.assertGreaterEqual(count, 0) + + def test_user_loved_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) + + # Act + loved = track.get_userloved() + + # Assert + self.assertIsNotNone(loved) + self.assertIsInstance(loved, bool) + self.assertNotIsInstance(loved, str) + + def test_track_is_hashable(self): + # Arrange + artist = self.network.get_artist("Test Artist") + track = artist.get_top_tracks()[0].item + self.assertIsInstance(track, pylast.Track) + + # Act/Assert + self.helper_is_thing_hashable(track) + + def test_track_wiki_content(self): + # Arrange + track = pylast.Track("Test Artist", "test title", self.network) + + # Act + wiki = track.get_wiki_content() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_track_wiki_summary(self): + # Arrange + track = pylast.Track("Test Artist", "test title", self.network) + + # Act + wiki = track.get_wiki_summary() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + +class TestPyLastUser(PyLastTestCase): + + def test_get_user_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + registered = user.get_registered() + + # Assert + # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp + if int(registered): + pytest.skip("Last.fm API is broken.") + + # Just check date because of timezones + self.assertIn(u"2002-11-20 ", registered) + + def test_get_user_unixtime_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + unixtime_registered = user.get_unixtime_registered() + + # Assert + # Just check date because of timezones + self.assertEqual(unixtime_registered, u"1037793040") + + def test_get_genderless_user(self): + # Arrange + # Currently test_user has no gender set: + lastfm_user = self.network.get_user("test_user") + + # Act + gender = lastfm_user.get_gender() + + # Assert + self.assertIsNone(gender) + + def test_get_countryless_user(self): + # Arrange + # Currently test_user has no country set: + lastfm_user = self.network.get_user("test_user") + + # Act + country = lastfm_user.get_country() + + # Assert + self.assertIsNone(country) + + def test_user_equals_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user is None) + + # Assert + self.assertFalse(value) + + def test_user_not_equal_to_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user is not None) + + # Assert + self.assertTrue(value) + + def test_now_playing_user_with_no_scrobbles(self): + # Arrange + # Currently test-account has no scrobbles: + user = self.network.get_user('test-account') + + # Act + current_track = user.get_now_playing() + + # Assert + self.assertIsNone(current_track) + + def test_love_limits(self): + # Arrange + # Currently test-account has at least 23 loved tracks: + user = self.network.get_user("test-user") + + # Act/Assert + self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) + self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + + def test_user_is_hashable(self): + # Arrange + user = self.network.get_user(self.username) + + # Act/Assert + self.helper_is_thing_hashable(user) + + # Commented out because (a) it'll take a long time and (b) it strangely + # fails due Last.fm's complaining of hitting the rate limit, even when + # limited to one call per second. The ToS allows 5 calls per second. + # def test_get_all_scrobbles(self): + # # Arrange + # lastfm_user = self.network.get_user("RJ") + # self.network.enable_rate_limit() # this is going to be slow... + + # # Act + # tracks = lastfm_user.get_recent_tracks(limit=None) + + # # Assert + # self.assertGreaterEqual(len(tracks), 0) + + def test_pickle(self): + # Arrange + import pickle + lastfm_user = self.network.get_user(self.username) + filename = str(self.unix_timestamp()) + ".pkl" + + # Act + with open(filename, "wb") as f: + pickle.dump(lastfm_user, f) + with open(filename, "rb") as f: + loaded_user = pickle.load(f) + os.remove(filename) + + # Assert + self.assertEqual(lastfm_user, loaded_user) + + def test_cacheable_user_artist_tracks(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act + result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) + result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) + result3 = lastfm_user.get_artist_tracks("Test Artist") + + # Assert + self.helper_validate_results(result1, result2, result3) + + def test_cacheable_user(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act/Assert + self.helper_validate_cacheable(lastfm_user, "get_friends") + self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") + self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") + + def test_user_get_top_tags_with_limit(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + tags = user.get_top_tags(limit=1) + + # Assert + self.skip_if_lastfm_api_broken(tags) + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + def test_user_top_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + things = lastfm_user.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def helper_assert_chart(self, chart, expected_type): + # Assert + self.assertIsNotNone(chart) + self.assertGreater(len(chart), 0) + self.assertIsInstance(chart[0], pylast.TopItem) + self.assertIsInstance(chart[0].item, expected_type) + + def helper_get_assert_charts(self, thing, date): + # Arrange + (from_date, to_date) = date + + # Act + artist_chart = thing.get_weekly_artist_charts(from_date, to_date) + if type(thing) is not pylast.Tag: + album_chart = thing.get_weekly_album_charts(from_date, to_date) + track_chart = thing.get_weekly_track_charts(from_date, to_date) + + # Assert + self.helper_assert_chart(artist_chart, pylast.Artist) + if type(thing) is not pylast.Tag: + self.helper_assert_chart(album_chart, pylast.Album) + self.helper_assert_chart(track_chart, pylast.Track) + + def helper_dates_valid(self, dates): + # Assert + self.assertGreaterEqual(len(dates), 1) + self.assertIsInstance(dates[0], tuple) + (start, end) = dates[0] + self.assertLess(start, end) + + def test_user_charts(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + dates = lastfm_user.get_weekly_chart_dates() + self.helper_dates_valid(dates) + + # Act/Assert + self.helper_get_assert_charts(lastfm_user, dates[0]) + + # Commented out to avoid spamming + # def test_share_spam(self): + # # Arrange + # users_to_spam = [TODO_ENTER_SPAMEES_HERE] + # spam_message = "Dig the krazee sound!" + # artist = self.network.get_top_artists(limit=1)[0].item + # track = artist.get_top_tracks(limit=1)[0].item + + # # Act + # artist.share(users_to_spam, spam_message) + # track.share(users_to_spam, spam_message) + + # Assert + # Check inbox for spam! + + # album/artist/track/user + + def test_user_top_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + artists = lastfm_user.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_user_top_albums(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + albums = user.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_user_tagged_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["artisttagola"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + + # Act + artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(artists, pylast.Artist) + + def test_user_tagged_albums(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["albumtagola"] + album = self.network.get_album("Test Artist", "Test Album") + album.add_tags(tags) + + # Act + albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(albums, pylast.Album) + + def test_user_tagged_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["tracktagola"] + track = self.network.get_track("Test Artist", "test title") + track.add_tags(tags) + + # Act + tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(tracks, pylast.Track) + + def test_user_subscriber(self): + # Arrange + subscriber = self.network.get_user("RJ") + non_subscriber = self.network.get_user("Test User") + + # Act + subscriber_is_subscriber = subscriber.is_subscriber() + non_subscriber_is_subscriber = non_subscriber.is_subscriber() + + # Assert + self.assertTrue(subscriber_is_subscriber) + self.assertFalse(non_subscriber_is_subscriber) + + def test_user_get_image(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + url = user.get_image() + + # Assert + self.assertTrue(url.startswith("https://")) + + def test_user_get_library(self): + # Arrange + user = self.network.get_user(self.username) + + # Act + library = user.get_library() + + # Assert + self.assertIsInstance(library, pylast.Library) + + def test_get_recent_tracks_from_to(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + + from datetime import datetime + start = datetime(2011, 7, 21, 15, 10) + end = datetime(2011, 7, 21, 15, 15) + import calendar + utc_start = calendar.timegm(start.utctimetuple()) + utc_end = calendar.timegm(end.utctimetuple()) + + # Act + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, + time_to=utc_end) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") + self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + def test_tracks_notequal(self): # Arrange track1 = pylast.Track("Test Artist", "test title", self.network) @@ -1174,83 +1358,6 @@ class TestPyLast(PyLastTestCase): self.assertEqual(len(tracks), 4) self.assertTrue(url.startswith("https://www.last.fm/music/test")) - def test_tags(self): - # Arrange - tag1 = self.network.get_tag("blues") - tag2 = self.network.get_tag("rock") - - # Act - tag_repr = repr(tag1) - tag_str = str(tag1) - name = tag1.get_name(properly_capitalized=True) - url = tag1.get_url() - - # Assert - self.assertEqual("blues", tag_str) - self.assertIn("pylast.Tag", tag_repr) - self.assertIn("blues", tag_repr) - self.assertEqual("blues", name) - self.assertTrue(tag1 == tag1) - self.assertTrue(tag1 != tag2) - self.assertEqual(url, "https://www.last.fm/tag/blues") - - def test_tags_similar(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - similar = tag.get_similar() - - # Assert - self.skip_if_lastfm_api_broken(similar) - found = False - for tag in similar: - if tag.name == "delta blues": - found = True - break - self.assertTrue(found) - - def test_artists(self): - # Arrange - artist1 = self.network.get_artist("Radiohead") - artist2 = self.network.get_artist("Portishead") - - # Act - url = artist1.get_url() - mbid = artist1.get_mbid() - image = artist1.get_cover_image() - playcount = artist1.get_playcount() - streamable = artist1.is_streamable() - name = artist1.get_name(properly_capitalized=False) - name_cap = artist1.get_name(properly_capitalized=True) - - # Assert - self.assertIn("http", image) - self.assertGreater(playcount, 1) - self.assertTrue(artist1 != artist2) - self.assertEqual(name.lower(), name_cap.lower()) - self.assertEqual(url, "https://www.last.fm/music/radiohead") - self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") - self.assertIsInstance(streamable, bool) - - def test_countries(self): - # Arrange - country1 = pylast.Country("Italy", self.network) - country2 = pylast.Country("Finland", self.network) - - # Act - text = str(country1) - rep = repr(country1) - url = country1.get_url() - - # Assert - self.assertIn("Italy", rep) - self.assertIn("pylast.Country", rep) - self.assertEqual(text, "Italy") - self.assertTrue(country1 == country1) - self.assertTrue(country1 != country2) - self.assertEqual(url, "https://www.last.fm/place/italy") - def test_track_eq_none_is_false(self): # Arrange track1 = None @@ -1267,89 +1374,6 @@ class TestPyLast(PyLastTestCase): # Act / Assert self.assertTrue(track1 != track2) - def test_artist_eq_none_is_false(self): - # Arrange - artist1 = None - artist2 = pylast.Artist("Test Artist", self.network) - - # Act / Assert - self.assertFalse(artist1 == artist2) - - def test_artist_ne_none_is_true(self): - # Arrange - artist1 = None - artist2 = pylast.Artist("Test Artist", self.network) - - # Act / Assert - self.assertTrue(artist1 != artist2) - - def test_album_eq_none_is_false(self): - # Arrange - album1 = None - album2 = pylast.Album("Test Artist", "Test Album", self.network) - - # Act / Assert - self.assertFalse(album1 == album2) - - def test_album_ne_none_is_true(self): - # Arrange - album1 = None - album2 = pylast.Album("Test Artist", "Test Album", self.network) - - # Act / Assert - self.assertTrue(album1 != album2) - - def test_band_members(self): - # Arrange - artist = pylast.Artist("The Beatles", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.skip_if_lastfm_api_broken(band_members) - self.assertGreaterEqual(len(band_members), 4) - - def test_no_band_members(self): - # Arrange - artist = pylast.Artist("John Lennon", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.assertIsNone(band_members) - - def test_get_recent_tracks_from_to(self): - # Arrange - lastfm_user = self.network.get_user("RJ") - - from datetime import datetime - start = datetime(2011, 7, 21, 15, 10) - end = datetime(2011, 7, 21, 15, 15) - import calendar - utc_start = calendar.timegm(start.utctimetuple()) - utc_end = calendar.timegm(end.utctimetuple()) - - # Act - tracks = lastfm_user.get_recent_tracks(time_from=utc_start, - time_to=utc_end) - - # Assert - self.assertEqual(len(tracks), 1) - self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") - self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - - def test_artist_get_correction(self): - # Arrange - artist = pylast.Artist("guns and roses", self.network) - - # Act - corrected_artist_name = artist.get_correction() - - # Assert - self.assertEqual(corrected_artist_name, "Guns N' Roses") - def test_track_get_correction(self): # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) @@ -1370,21 +1394,6 @@ class TestPyLast(PyLastTestCase): # Assert self.assertEqual(mbid, None) - def test_init_with_token(self): - # Arrange/Act - try: - pylast.LastFMNetwork( - api_key=self.__class__.secrets["api_key"], - api_secret=self.__class__.secrets["api_secret"], - token="invalid", - ) - except pylast.WSError as exc: - msg = str(exc) - - # Assert - self.assertEqual(msg, - "Unauthorized Token - This token has not been issued") - @flaky(max_runs=5, min_passes=1) class TestPyLastWithLibreFm(unittest.TestCase): From 2aa4dbdf880f971c9474b2abf78d8a6c0405d382 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 17 Oct 2017 21:57:17 +0300 Subject: [PATCH 339/768] Split Last.fm/Libre.fm tests --- tests/test_pylast.py | 25 +++---------------------- tests/test_pylast_librefm.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 22 deletions(-) create mode 100755 tests/test_pylast_librefm.py diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 2342e02..7024067 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2,12 +2,13 @@ """ Integration (not unit) tests for pylast.py """ -from flaky import flaky import os -import pytest import time import unittest +import pytest +from flaky import flaky + import pylast @@ -1395,25 +1396,5 @@ class TestPyLastUser(PyLastTestCase): self.assertEqual(mbid, None) -@flaky(max_runs=5, min_passes=1) -class TestPyLastWithLibreFm(unittest.TestCase): - """Own class for Libre.fm because we don't need the Last.fm setUp""" - - def test_libre_fm(self): - # Arrange - secrets = load_secrets() - username = secrets["username"] - password_hash = secrets["password_hash"] - - # Act - network = pylast.LibreFMNetwork( - password_hash=password_hash, username=username) - artist = network.get_artist("Radiohead") - name = artist.get_name() - - # Assert - self.assertEqual(name, "Radiohead") - - if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_librefm.py b/tests/test_pylast_librefm.py new file mode 100755 index 0000000..61cdced --- /dev/null +++ b/tests/test_pylast_librefm.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +from flaky import flaky + +import pylast +from test_pylast import load_secrets + + +@flaky(max_runs=5, min_passes=1) +class TestPyLastWithLibreFm(unittest.TestCase): + """Own class for Libre.fm because we don't need the Last.fm setUp""" + + def test_libre_fm(self): + # Arrange + secrets = load_secrets() + username = secrets["username"] + password_hash = secrets["password_hash"] + + # Act + network = pylast.LibreFMNetwork( + password_hash=password_hash, username=username) + artist = network.get_artist("Radiohead") + name = artist.get_name() + + # Assert + self.assertEqual(name, "Radiohead") + + +if __name__ == '__main__': + unittest.main(failfast=True) From 2edfc108d1335966e2f63c94528eb557bba13c59 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 17 Oct 2017 23:14:00 +0300 Subject: [PATCH 340/768] Fix test import --- tests/test_pylast_librefm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast_librefm.py b/tests/test_pylast_librefm.py index 61cdced..3d2cb53 100755 --- a/tests/test_pylast_librefm.py +++ b/tests/test_pylast_librefm.py @@ -7,7 +7,7 @@ import unittest from flaky import flaky import pylast -from test_pylast import load_secrets +from .test_pylast import load_secrets @flaky(max_runs=5, min_passes=1) From 5ba51c1e2c50d4843edee1e133fdc93fac8e3456 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 17 Oct 2017 23:18:35 +0300 Subject: [PATCH 341/768] Finish refactor --- tests/test_pylast.py | 1272 ---------------------------------- tests/test_pylast_album.py | 104 +++ tests/test_pylast_artist.py | 262 +++++++ tests/test_pylast_country.py | 41 ++ tests/test_pylast_library.py | 30 + tests/test_pylast_librefm.py | 1 + tests/test_pylast_network.py | 307 ++++++++ tests/test_pylast_tag.py | 79 +++ tests/test_pylast_track.py | 109 +++ tests/test_pylast_user.py | 448 ++++++++++++ 10 files changed, 1381 insertions(+), 1272 deletions(-) create mode 100755 tests/test_pylast_album.py create mode 100755 tests/test_pylast_artist.py create mode 100755 tests/test_pylast_country.py create mode 100755 tests/test_pylast_library.py create mode 100755 tests/test_pylast_network.py create mode 100755 tests/test_pylast_tag.py create mode 100755 tests/test_pylast_track.py create mode 100755 tests/test_pylast_user.py diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 7024067..9279112 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -124,1277 +124,5 @@ class PyLastTestCase(unittest.TestCase): self.assertNotEqual(thing1, thing2) -class TestPyLastAlbum(PyLastTestCase): - - def test_album_tags_are_topitems(self): - # Arrange - albums = self.network.get_user('RJ').get_top_albums() - - # Act - tags = albums[0].item.get_top_tags(limit=1) - - # Assert - self.assertGreater(len(tags), 0) - self.assertIsInstance(tags[0], pylast.TopItem) - - def test_album_is_hashable(self): - # Arrange - album = self.network.get_album("Test Artist", "Test Album") - - # Act/Assert - self.helper_is_thing_hashable(album) - - def test_album_in_recent_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - # limit=2 to ignore now-playing: - track = lastfm_user.get_recent_tracks(limit=2)[0] - - # Assert - self.assertTrue(hasattr(track, 'album')) - - def test_album_in_artist_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] - - # Assert - self.assertTrue(hasattr(track, 'album')) - - def test_album_wiki_content(self): - # Arrange - album = pylast.Album("Test Artist", "Test Album", self.network) - - # Act - wiki = album.get_wiki_content() - - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - def test_album_wiki_published_date(self): - # Arrange - album = pylast.Album("Test Artist", "Test Album", self.network) - - # Act - wiki = album.get_wiki_published_date() - - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - def test_album_wiki_summary(self): - # Arrange - album = pylast.Album("Test Artist", "Test Album", self.network) - - # Act - wiki = album.get_wiki_summary() - - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - def test_album_eq_none_is_false(self): - # Arrange - album1 = None - album2 = pylast.Album("Test Artist", "Test Album", self.network) - - # Act / Assert - self.assertFalse(album1 == album2) - - def test_album_ne_none_is_true(self): - # Arrange - album1 = None - album2 = pylast.Album("Test Artist", "Test Album", self.network) - - # Act / Assert - self.assertTrue(album1 != album2) - - -class TestPyLastArtist(PyLastTestCase): - - def test_artist_is_hashable(self): - # Arrange - test_artist = self.network.get_artist("Test Artist") - artist = test_artist.get_similar(limit=2)[0].item - self.assertIsInstance(artist, pylast.Artist) - - # Act/Assert - self.helper_is_thing_hashable(artist) - - def test_bio_published_date(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_published_date() - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - - def test_bio_content(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_content(language="en") - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - - def test_bio_summary(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - - # Act - bio = artist.get_bio_summary(language="en") - - # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) - - def test_artist_top_tracks(self): - # Arrange - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - things = artist.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_artist_top_albums(self): - # Arrange - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - things = artist.get_top_albums(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Album) - - def test_artist_listener_count(self): - # Arrange - artist = self.network.get_artist("Test Artist") - - # Act - count = artist.get_listener_count() - - # Assert - self.assertIsInstance(count, int) - self.assertGreater(count, 0) - - def test_tag_artist(self): - # Arrange - artist = self.network.get_artist("Test Artist") -# artist.clear_tags() - - # Act - artist.add_tag("testing") - - # Assert - tags = artist.get_tags() - self.assertGreater(len(tags), 0) - found = False - for tag in tags: - if tag.name == "testing": - found = True - break - self.assertTrue(found) - - def test_remove_tag_of_type_text(self): - # Arrange - tag = "testing" # text - artist = self.network.get_artist("Test Artist") - artist.add_tag(tag) - - # Act - artist.remove_tag(tag) - - # Assert - tags = artist.get_tags() - found = False - for tag in tags: - if tag.name == "testing": - found = True - break - self.assertFalse(found) - - def test_remove_tag_of_type_tag(self): - # Arrange - tag = pylast.Tag("testing", self.network) # Tag - artist = self.network.get_artist("Test Artist") - artist.add_tag(tag) - - # Act - artist.remove_tag(tag) - - # Assert - tags = artist.get_tags() - found = False - for tag in tags: - if tag.name == "testing": - found = True - break - self.assertFalse(found) - - def test_remove_tags(self): - # Arrange - tags = ["removetag1", "removetag2"] - artist = self.network.get_artist("Test Artist") - artist.add_tags(tags) - artist.add_tags("1more") - tags_before = artist.get_tags() - - # Act - artist.remove_tags(tags) - - # Assert - tags_after = artist.get_tags() - self.assertEqual(len(tags_after), len(tags_before) - 2) - found1, found2 = False, False - for tag in tags_after: - if tag.name == "removetag1": - found1 = True - elif tag.name == "removetag2": - found2 = True - self.assertFalse(found1) - self.assertFalse(found2) - - def test_set_tags(self): - # Arrange - tags = ["sometag1", "sometag2"] - artist = self.network.get_artist("Test Artist 2") - artist.add_tags(tags) - tags_before = artist.get_tags() - new_tags = ["settag1", "settag2"] - - # Act - artist.set_tags(new_tags) - - # Assert - tags_after = artist.get_tags() - self.assertNotEqual(tags_before, tags_after) - self.assertEqual(len(tags_after), 2) - found1, found2 = False, False - for tag in tags_after: - if tag.name == "settag1": - found1 = True - elif tag.name == "settag2": - found2 = True - self.assertTrue(found1) - self.assertTrue(found2) - - def test_artists(self): - # Arrange - artist1 = self.network.get_artist("Radiohead") - artist2 = self.network.get_artist("Portishead") - - # Act - url = artist1.get_url() - mbid = artist1.get_mbid() - image = artist1.get_cover_image() - playcount = artist1.get_playcount() - streamable = artist1.is_streamable() - name = artist1.get_name(properly_capitalized=False) - name_cap = artist1.get_name(properly_capitalized=True) - - # Assert - self.assertIn("http", image) - self.assertGreater(playcount, 1) - self.assertTrue(artist1 != artist2) - self.assertEqual(name.lower(), name_cap.lower()) - self.assertEqual(url, "https://www.last.fm/music/radiohead") - self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") - self.assertIsInstance(streamable, bool) - - def test_artist_eq_none_is_false(self): - # Arrange - artist1 = None - artist2 = pylast.Artist("Test Artist", self.network) - - # Act / Assert - self.assertFalse(artist1 == artist2) - - def test_artist_ne_none_is_true(self): - # Arrange - artist1 = None - artist2 = pylast.Artist("Test Artist", self.network) - - # Act / Assert - self.assertTrue(artist1 != artist2) - - def test_band_members(self): - # Arrange - artist = pylast.Artist("The Beatles", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.skip_if_lastfm_api_broken(band_members) - self.assertGreaterEqual(len(band_members), 4) - - def test_no_band_members(self): - # Arrange - artist = pylast.Artist("John Lennon", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.assertIsNone(band_members) - - def test_artist_get_correction(self): - # Arrange - artist = pylast.Artist("guns and roses", self.network) - - # Act - corrected_artist_name = artist.get_correction() - - # Assert - self.assertEqual(corrected_artist_name, "Guns N' Roses") - - -class TestPyLastCountry(PyLastTestCase): - - def test_country_is_hashable(self): - # Arrange - country = self.network.get_country("Italy") - - # Act/Assert - self.helper_is_thing_hashable(country) - - def test_countries(self): - # Arrange - country1 = pylast.Country("Italy", self.network) - country2 = pylast.Country("Finland", self.network) - - # Act - text = str(country1) - rep = repr(country1) - url = country1.get_url() - - # Assert - self.assertIn("Italy", rep) - self.assertIn("pylast.Country", rep) - self.assertEqual(text, "Italy") - self.assertTrue(country1 == country1) - self.assertTrue(country1 != country2) - self.assertEqual(url, "https://www.last.fm/place/italy") - - -class TestPyLastLibrary(PyLastTestCase): - - def test_library_is_hashable(self): - # Arrange - library = pylast.Library(user=self.username, network=self.network) - - # Act/Assert - self.helper_is_thing_hashable(library) - - def test_cacheable_library(self): - # Arrange - library = pylast.Library(self.username, self.network) - - # Act/Assert - self.helper_validate_cacheable(library, "get_artists") - - -class TestPyLastNetwork(PyLastTestCase): - - def test_scrobble(self): - # Arrange - artist = "Test Artist" - title = "test title" - timestamp = self.unix_timestamp() - lastfm_user = self.network.get_user(self.username) - - # Act - self.network.scrobble(artist=artist, title=title, timestamp=timestamp) - - # Assert - # limit=2 to ignore now-playing: - last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] - self.assertEqual(str(last_scrobble.track.artist), str(artist)) - self.assertEqual(str(last_scrobble.track.title), str(title)) - self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) - - def test_update_now_playing(self): - # Arrange - artist = "Test Artist" - title = "test title" - album = "Test Album" - track_number = 1 - lastfm_user = self.network.get_user(self.username) - - # Act - self.network.update_now_playing( - artist=artist, title=title, album=album, track_number=track_number) - - # Assert - current_track = lastfm_user.get_now_playing() - self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title), "test title") - self.assertEqual(str(current_track.artist), "Test Artist") - - def test_invalid_xml(self): - # Arrange - # Currently causes PCDATA invalid Char value 25 - artist = "Blind Willie Johnson" - title = "It's nobody's fault but mine" - - # Act - search = self.network.search_for_track(artist, title) - total = search.get_total_result_count() - - # Assert - self.skip_if_lastfm_api_broken(total) - self.assertGreaterEqual(int(total), 0) - - def test_enable_rate_limiting(self): - # Arrange - self.assertFalse(self.network.is_rate_limited()) - - # Act - self.network.enable_rate_limit() - then = time.time() - # Make some network call, limit not applied first time - self.network.get_user(self.username) - # Make a second network call, limiting should be applied - self.network.get_top_artists() - now = time.time() - - # Assert - self.assertTrue(self.network.is_rate_limited()) - self.assertGreaterEqual(now - then, 0.2) - - def test_disable_rate_limiting(self): - # Arrange - self.network.enable_rate_limit() - self.assertTrue(self.network.is_rate_limited()) - - # Act - self.network.disable_rate_limit() - # Make some network call, limit not applied first time - self.network.get_user(self.username) - # Make a second network call, limiting should be applied - self.network.get_top_artists() - - # Assert - self.assertFalse(self.network.is_rate_limited()) - - def test_lastfm_network_name(self): - # Act - name = str(self.network) - - # Assert - self.assertEqual(name, "Last.fm Network") - - def test_geo_get_top_artists(self): - # Arrange - # Act - artists = self.network.get_geo_top_artists( - country="United Kingdom", limit=1) - - # Assert - self.assertEqual(len(artists), 1) - self.assertIsInstance(artists[0], pylast.TopItem) - self.assertIsInstance(artists[0].item, pylast.Artist) - - def test_geo_get_top_tracks(self): - # Arrange - # Act - tracks = self.network.get_geo_top_tracks( - country="United Kingdom", location="Manchester", limit=1) - - # Assert - self.assertEqual(len(tracks), 1) - self.assertIsInstance(tracks[0], pylast.TopItem) - self.assertIsInstance(tracks[0].item, pylast.Track) - - def test_network_get_top_artists_with_limit(self): - # Arrange - # Act - artists = self.network.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_network_get_top_tags_with_limit(self): - # Arrange - # Act - tags = self.network.get_top_tags(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - - def test_network_get_top_tags_with_no_limit(self): - # Arrange - # Act - tags = self.network.get_top_tags() - - # Assert - self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) - - def test_network_get_top_tracks_with_limit(self): - # Arrange - # Act - tracks = self.network.get_top_tracks(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(tracks, pylast.Track) - - def test_country_top_tracks(self): - # Arrange - country = self.network.get_country("Croatia") - - # Act - things = country.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_country_network_top_tracks(self): - # Arrange - # Act - things = self.network.get_geo_top_tracks("Croatia", limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_tag_top_tracks(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - things = tag.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def test_album_data(self): - # Arrange - thing = self.network.get_album("Test Artist", "Test Album") - - # Act - stringed = str(thing) - repr = thing.__repr__() - title = thing.get_title() - name = thing.get_name() - playcount = thing.get_playcount() - url = thing.get_url() - - # Assert - self.assertEqual(stringed, "Test Artist - Test Album") - self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/music/test%2bartist/test%2balbum", url) - - def test_track_data(self): - # Arrange - thing = self.network.get_track("Test Artist", "test title") - - # Act - stringed = str(thing) - repr = thing.__repr__() - title = thing.get_title() - name = thing.get_name() - playcount = thing.get_playcount() - url = thing.get_url(pylast.DOMAIN_FRENCH) - - # Assert - self.assertEqual(stringed, "Test Artist - test title") - self.assertIn("pylast.Track('Test Artist', 'test title',", repr) - self.assertEqual(title, "test title") - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) - - def test_country_top_artists(self): - # Arrange - country = self.network.get_country("Ukraine") - - # Act - artists = country.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_caching(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - self.network.enable_caching() - tags1 = user.get_top_tags(limit=1, cacheable=True) - tags2 = user.get_top_tags(limit=1, cacheable=True) - - # Assert - self.assertTrue(self.network.is_caching_enabled()) - self.assertEqual(tags1, tags2) - self.network.disable_caching() - self.assertFalse(self.network.is_caching_enabled()) - - def test_album_mbid(self): - # Arrange - mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" - - # Act - album = self.network.get_album_by_mbid(mbid) - album_mbid = album.get_mbid() - - # Assert - self.assertIsInstance(album, pylast.Album) - self.assertEqual(album.title.lower(), "test") - self.assertEqual(album_mbid, mbid) - - def test_artist_mbid(self): - # Arrange - mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" - - # Act - artist = self.network.get_artist_by_mbid(mbid) - - # Assert - self.assertIsInstance(artist, pylast.Artist) - self.assertEqual(artist.name, "MusicBrainz Test Artist") - - def test_track_mbid(self): - # Arrange - mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" - - # Act - track = self.network.get_track_by_mbid(mbid) - track_mbid = track.get_mbid() - - # Assert - self.assertIsInstance(track, pylast.Track) - self.assertEqual(track.title, "first") - self.assertEqual(track_mbid, mbid) - - def test_init_with_token(self): - # Arrange/Act - try: - pylast.LastFMNetwork( - api_key=self.__class__.secrets["api_key"], - api_secret=self.__class__.secrets["api_secret"], - token="invalid", - ) - except pylast.WSError as exc: - msg = str(exc) - - # Assert - self.assertEqual(msg, - "Unauthorized Token - This token has not been issued") - - -class TestPyLastTag(PyLastTestCase): - - def test_tag_is_hashable(self): - # Arrange - tag = self.network.get_top_tags(limit=1)[0] - - # Act/Assert - self.helper_is_thing_hashable(tag) - - def test_tag_top_artists(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - artists = tag.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_tag_top_albums(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - albums = tag.get_top_albums(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(albums, pylast.Album) - - def test_tags(self): - # Arrange - tag1 = self.network.get_tag("blues") - tag2 = self.network.get_tag("rock") - - # Act - tag_repr = repr(tag1) - tag_str = str(tag1) - name = tag1.get_name(properly_capitalized=True) - url = tag1.get_url() - - # Assert - self.assertEqual("blues", tag_str) - self.assertIn("pylast.Tag", tag_repr) - self.assertIn("blues", tag_repr) - self.assertEqual("blues", name) - self.assertTrue(tag1 == tag1) - self.assertTrue(tag1 != tag2) - self.assertEqual(url, "https://www.last.fm/tag/blues") - - def test_tags_similar(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - similar = tag.get_similar() - - # Assert - self.skip_if_lastfm_api_broken(similar) - found = False - for tag in similar: - if tag.name == "delta blues": - found = True - break - self.assertTrue(found) - - -class TestPyLastTrack(PyLastTestCase): - - def test_love(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = self.network.get_track(artist, title) - lastfm_user = self.network.get_user(self.username) - - # Act - track.love() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - self.assertEqual(str(loved[0].track.artist), "Test Artist") - self.assertEqual(str(loved[0].track.title), "test title") - - def test_unlove(self): - # Arrange - artist = pylast.Artist("Test Artist", self.network) - title = "test title" - track = pylast.Track(artist, title, self.network) - lastfm_user = self.network.get_user(self.username) - track.love() - - # Act - track.unlove() - - # Assert - loved = lastfm_user.get_loved_tracks(limit=1) - if len(loved): # OK to be empty but if not: - self.assertNotEqual(str(loved.track.artist), "Test Artist") - self.assertNotEqual(str(loved.track.title), "test title") - - def test_user_play_count_in_track_info(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) - - # Act - count = track.get_userplaycount() - - # Assert - self.assertGreaterEqual(count, 0) - - def test_user_loved_in_track_info(self): - # Arrange - artist = "Test Artist" - title = "test title" - track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) - - # Act - loved = track.get_userloved() - - # Assert - self.assertIsNotNone(loved) - self.assertIsInstance(loved, bool) - self.assertNotIsInstance(loved, str) - - def test_track_is_hashable(self): - # Arrange - artist = self.network.get_artist("Test Artist") - track = artist.get_top_tracks()[0].item - self.assertIsInstance(track, pylast.Track) - - # Act/Assert - self.helper_is_thing_hashable(track) - - def test_track_wiki_content(self): - # Arrange - track = pylast.Track("Test Artist", "test title", self.network) - - # Act - wiki = track.get_wiki_content() - - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - def test_track_wiki_summary(self): - # Arrange - track = pylast.Track("Test Artist", "test title", self.network) - - # Act - wiki = track.get_wiki_summary() - - # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) - - -class TestPyLastUser(PyLastTestCase): - - def test_get_user_registration(self): - # Arrange - username = "RJ" - user = self.network.get_user(username) - - # Act - registered = user.get_registered() - - # Assert - # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp - if int(registered): - pytest.skip("Last.fm API is broken.") - - # Just check date because of timezones - self.assertIn(u"2002-11-20 ", registered) - - def test_get_user_unixtime_registration(self): - # Arrange - username = "RJ" - user = self.network.get_user(username) - - # Act - unixtime_registered = user.get_unixtime_registered() - - # Assert - # Just check date because of timezones - self.assertEqual(unixtime_registered, u"1037793040") - - def test_get_genderless_user(self): - # Arrange - # Currently test_user has no gender set: - lastfm_user = self.network.get_user("test_user") - - # Act - gender = lastfm_user.get_gender() - - # Assert - self.assertIsNone(gender) - - def test_get_countryless_user(self): - # Arrange - # Currently test_user has no country set: - lastfm_user = self.network.get_user("test_user") - - # Act - country = lastfm_user.get_country() - - # Assert - self.assertIsNone(country) - - def test_user_equals_none(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - value = (lastfm_user is None) - - # Assert - self.assertFalse(value) - - def test_user_not_equal_to_none(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - value = (lastfm_user is not None) - - # Assert - self.assertTrue(value) - - def test_now_playing_user_with_no_scrobbles(self): - # Arrange - # Currently test-account has no scrobbles: - user = self.network.get_user('test-account') - - # Act - current_track = user.get_now_playing() - - # Assert - self.assertIsNone(current_track) - - def test_love_limits(self): - # Arrange - # Currently test-account has at least 23 loved tracks: - user = self.network.get_user("test-user") - - # Act/Assert - self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) - self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) - - def test_user_is_hashable(self): - # Arrange - user = self.network.get_user(self.username) - - # Act/Assert - self.helper_is_thing_hashable(user) - - # Commented out because (a) it'll take a long time and (b) it strangely - # fails due Last.fm's complaining of hitting the rate limit, even when - # limited to one call per second. The ToS allows 5 calls per second. - # def test_get_all_scrobbles(self): - # # Arrange - # lastfm_user = self.network.get_user("RJ") - # self.network.enable_rate_limit() # this is going to be slow... - - # # Act - # tracks = lastfm_user.get_recent_tracks(limit=None) - - # # Assert - # self.assertGreaterEqual(len(tracks), 0) - - def test_pickle(self): - # Arrange - import pickle - lastfm_user = self.network.get_user(self.username) - filename = str(self.unix_timestamp()) + ".pkl" - - # Act - with open(filename, "wb") as f: - pickle.dump(lastfm_user, f) - with open(filename, "rb") as f: - loaded_user = pickle.load(f) - os.remove(filename) - - # Assert - self.assertEqual(lastfm_user, loaded_user) - - def test_cacheable_user_artist_tracks(self): - # Arrange - lastfm_user = self.network.get_authenticated_user() - - # Act - result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) - result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) - result3 = lastfm_user.get_artist_tracks("Test Artist") - - # Assert - self.helper_validate_results(result1, result2, result3) - - def test_cacheable_user(self): - # Arrange - lastfm_user = self.network.get_authenticated_user() - - # Act/Assert - self.helper_validate_cacheable(lastfm_user, "get_friends") - self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") - self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") - - def test_user_get_top_tags_with_limit(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - tags = user.get_top_tags(limit=1) - - # Assert - self.skip_if_lastfm_api_broken(tags) - self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - - def test_user_top_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - things = lastfm_user.get_top_tracks(limit=2) - - # Assert - self.helper_two_different_things_in_top_list(things, pylast.Track) - - def helper_assert_chart(self, chart, expected_type): - # Assert - self.assertIsNotNone(chart) - self.assertGreater(len(chart), 0) - self.assertIsInstance(chart[0], pylast.TopItem) - self.assertIsInstance(chart[0].item, expected_type) - - def helper_get_assert_charts(self, thing, date): - # Arrange - (from_date, to_date) = date - - # Act - artist_chart = thing.get_weekly_artist_charts(from_date, to_date) - if type(thing) is not pylast.Tag: - album_chart = thing.get_weekly_album_charts(from_date, to_date) - track_chart = thing.get_weekly_track_charts(from_date, to_date) - - # Assert - self.helper_assert_chart(artist_chart, pylast.Artist) - if type(thing) is not pylast.Tag: - self.helper_assert_chart(album_chart, pylast.Album) - self.helper_assert_chart(track_chart, pylast.Track) - - def helper_dates_valid(self, dates): - # Assert - self.assertGreaterEqual(len(dates), 1) - self.assertIsInstance(dates[0], tuple) - (start, end) = dates[0] - self.assertLess(start, end) - - def test_user_charts(self): - # Arrange - lastfm_user = self.network.get_user("RJ") - dates = lastfm_user.get_weekly_chart_dates() - self.helper_dates_valid(dates) - - # Act/Assert - self.helper_get_assert_charts(lastfm_user, dates[0]) - - # Commented out to avoid spamming - # def test_share_spam(self): - # # Arrange - # users_to_spam = [TODO_ENTER_SPAMEES_HERE] - # spam_message = "Dig the krazee sound!" - # artist = self.network.get_top_artists(limit=1)[0].item - # track = artist.get_top_tracks(limit=1)[0].item - - # # Act - # artist.share(users_to_spam, spam_message) - # track.share(users_to_spam, spam_message) - - # Assert - # Check inbox for spam! - - # album/artist/track/user - - def test_user_top_artists(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - artists = lastfm_user.get_top_artists(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - - def test_user_top_albums(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - albums = user.get_top_albums(limit=1) - - # Assert - self.helper_only_one_thing_in_top_list(albums, pylast.Album) - - def test_user_tagged_artists(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["artisttagola"] - artist = self.network.get_artist("Test Artist") - artist.add_tags(tags) - - # Act - artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(artists, pylast.Artist) - - def test_user_tagged_albums(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["albumtagola"] - album = self.network.get_album("Test Artist", "Test Album") - album.add_tags(tags) - - # Act - albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(albums, pylast.Album) - - def test_user_tagged_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - tags = ["tracktagola"] - track = self.network.get_track("Test Artist", "test title") - track.add_tags(tags) - - # Act - tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) - - # Assert - self.helper_only_one_thing_in_list(tracks, pylast.Track) - - def test_user_subscriber(self): - # Arrange - subscriber = self.network.get_user("RJ") - non_subscriber = self.network.get_user("Test User") - - # Act - subscriber_is_subscriber = subscriber.is_subscriber() - non_subscriber_is_subscriber = non_subscriber.is_subscriber() - - # Assert - self.assertTrue(subscriber_is_subscriber) - self.assertFalse(non_subscriber_is_subscriber) - - def test_user_get_image(self): - # Arrange - user = self.network.get_user("RJ") - - # Act - url = user.get_image() - - # Assert - self.assertTrue(url.startswith("https://")) - - def test_user_get_library(self): - # Arrange - user = self.network.get_user(self.username) - - # Act - library = user.get_library() - - # Assert - self.assertIsInstance(library, pylast.Library) - - def test_get_recent_tracks_from_to(self): - # Arrange - lastfm_user = self.network.get_user("RJ") - - from datetime import datetime - start = datetime(2011, 7, 21, 15, 10) - end = datetime(2011, 7, 21, 15, 15) - import calendar - utc_start = calendar.timegm(start.utctimetuple()) - utc_end = calendar.timegm(end.utctimetuple()) - - # Act - tracks = lastfm_user.get_recent_tracks(time_from=utc_start, - time_to=utc_end) - - # Assert - self.assertEqual(len(tracks), 1) - self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") - self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - - def test_tracks_notequal(self): - # Arrange - track1 = pylast.Track("Test Artist", "test title", self.network) - track2 = pylast.Track("Test Artist", "Test Track", self.network) - - # Act - # Assert - self.assertNotEqual(track1, track2) - - def test_track_id(self): - # Arrange - track = pylast.Track("Test Artist", "test title", self.network) - - # Act - id = track.get_id() - - # Assert - self.skip_if_lastfm_api_broken(id) - self.assertEqual(id, "14053327") - - def test_track_title_prop_caps(self): - # Arrange - track = pylast.Track("test artist", "test title", self.network) - - # Act - title = track.get_title(properly_capitalized=True) - - # Assert - self.assertEqual(title, "test title") - - def test_track_listener_count(self): - # Arrange - track = pylast.Track("test artist", "test title", self.network) - - # Act - count = track.get_listener_count() - - # Assert - self.assertGreater(count, 21) - - def test_album_tracks(self): - # Arrange - album = pylast.Album("Test Artist", "Test Release", self.network) - - # Act - tracks = album.get_tracks() - url = tracks[0].get_url() - - # Assert - self.assertIsInstance(tracks, list) - self.assertIsInstance(tracks[0], pylast.Track) - self.assertEqual(len(tracks), 4) - self.assertTrue(url.startswith("https://www.last.fm/music/test")) - - def test_track_eq_none_is_false(self): - # Arrange - track1 = None - track2 = pylast.Track("Test Artist", "test title", self.network) - - # Act / Assert - self.assertFalse(track1 == track2) - - def test_track_ne_none_is_true(self): - # Arrange - track1 = None - track2 = pylast.Track("Test Artist", "test title", self.network) - - # Act / Assert - self.assertTrue(track1 != track2) - - def test_track_get_correction(self): - # Arrange - track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) - - # Act - corrected_track_name = track.get_correction() - - # Assert - self.assertEqual(corrected_track_name, "Mr. Brownstone") - - def test_track_with_no_mbid(self): - # Arrange - track = pylast.Track("Static-X", "Set It Off", self.network) - - # Act - mbid = track.get_mbid() - - # Assert - self.assertEqual(mbid, None) - - if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_album.py b/tests/test_pylast_album.py new file mode 100755 index 0000000..a93c27d --- /dev/null +++ b/tests/test_pylast_album.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastAlbum(PyLastTestCase): + + def test_album_tags_are_topitems(self): + # Arrange + albums = self.network.get_user('RJ').get_top_albums() + + # Act + tags = albums[0].item.get_top_tags(limit=1) + + # Assert + self.assertGreater(len(tags), 0) + self.assertIsInstance(tags[0], pylast.TopItem) + + def test_album_is_hashable(self): + # Arrange + album = self.network.get_album("Test Artist", "Test Album") + + # Act/Assert + self.helper_is_thing_hashable(album) + + def test_album_in_recent_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + # limit=2 to ignore now-playing: + track = lastfm_user.get_recent_tracks(limit=2)[0] + + # Assert + self.assertTrue(hasattr(track, 'album')) + + def test_album_in_artist_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] + + # Assert + self.assertTrue(hasattr(track, 'album')) + + def test_album_wiki_content(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki_content() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_album_wiki_published_date(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki_published_date() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_album_wiki_summary(self): + # Arrange + album = pylast.Album("Test Artist", "Test Album", self.network) + + # Act + wiki = album.get_wiki_summary() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_album_eq_none_is_false(self): + # Arrange + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) + + # Act / Assert + self.assertFalse(album1 == album2) + + def test_album_ne_none_is_true(self): + # Arrange + album1 = None + album2 = pylast.Album("Test Artist", "Test Album", self.network) + + # Act / Assert + self.assertTrue(album1 != album2) + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py new file mode 100755 index 0000000..4fe15a2 --- /dev/null +++ b/tests/test_pylast_artist.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastArtist(PyLastTestCase): + + def test_artist_is_hashable(self): + # Arrange + test_artist = self.network.get_artist("Test Artist") + artist = test_artist.get_similar(limit=2)[0].item + self.assertIsInstance(artist, pylast.Artist) + + # Act/Assert + self.helper_is_thing_hashable(artist) + + def test_bio_published_date(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_published_date() + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + + def test_bio_content(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_content(language="en") + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + + def test_bio_summary(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + bio = artist.get_bio_summary(language="en") + + # Assert + self.assertIsNotNone(bio) + self.assertGreaterEqual(len(bio), 1) + + def test_artist_top_tracks(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_artist_top_albums(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Album) + + def test_artist_listener_count(self): + # Arrange + artist = self.network.get_artist("Test Artist") + + # Act + count = artist.get_listener_count() + + # Assert + self.assertIsInstance(count, int) + self.assertGreater(count, 0) + + def test_tag_artist(self): + # Arrange + artist = self.network.get_artist("Test Artist") +# artist.clear_tags() + + # Act + artist.add_tag("testing") + + # Assert + tags = artist.get_tags() + self.assertGreater(len(tags), 0) + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertTrue(found) + + def test_remove_tag_of_type_text(self): + # Arrange + tag = "testing" # text + artist = self.network.get_artist("Test Artist") + artist.add_tag(tag) + + # Act + artist.remove_tag(tag) + + # Assert + tags = artist.get_tags() + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertFalse(found) + + def test_remove_tag_of_type_tag(self): + # Arrange + tag = pylast.Tag("testing", self.network) # Tag + artist = self.network.get_artist("Test Artist") + artist.add_tag(tag) + + # Act + artist.remove_tag(tag) + + # Assert + tags = artist.get_tags() + found = False + for tag in tags: + if tag.name == "testing": + found = True + break + self.assertFalse(found) + + def test_remove_tags(self): + # Arrange + tags = ["removetag1", "removetag2"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + artist.add_tags("1more") + tags_before = artist.get_tags() + + # Act + artist.remove_tags(tags) + + # Assert + tags_after = artist.get_tags() + self.assertEqual(len(tags_after), len(tags_before) - 2) + found1, found2 = False, False + for tag in tags_after: + if tag.name == "removetag1": + found1 = True + elif tag.name == "removetag2": + found2 = True + self.assertFalse(found1) + self.assertFalse(found2) + + def test_set_tags(self): + # Arrange + tags = ["sometag1", "sometag2"] + artist = self.network.get_artist("Test Artist 2") + artist.add_tags(tags) + tags_before = artist.get_tags() + new_tags = ["settag1", "settag2"] + + # Act + artist.set_tags(new_tags) + + # Assert + tags_after = artist.get_tags() + self.assertNotEqual(tags_before, tags_after) + self.assertEqual(len(tags_after), 2) + found1, found2 = False, False + for tag in tags_after: + if tag.name == "settag1": + found1 = True + elif tag.name == "settag2": + found2 = True + self.assertTrue(found1) + self.assertTrue(found2) + + def test_artists(self): + # Arrange + artist1 = self.network.get_artist("Radiohead") + artist2 = self.network.get_artist("Portishead") + + # Act + url = artist1.get_url() + mbid = artist1.get_mbid() + image = artist1.get_cover_image() + playcount = artist1.get_playcount() + streamable = artist1.is_streamable() + name = artist1.get_name(properly_capitalized=False) + name_cap = artist1.get_name(properly_capitalized=True) + + # Assert + self.assertIn("http", image) + self.assertGreater(playcount, 1) + self.assertTrue(artist1 != artist2) + self.assertEqual(name.lower(), name_cap.lower()) + self.assertEqual(url, "https://www.last.fm/music/radiohead") + self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") + self.assertIsInstance(streamable, bool) + + def test_artist_eq_none_is_false(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertFalse(artist1 == artist2) + + def test_artist_ne_none_is_true(self): + # Arrange + artist1 = None + artist2 = pylast.Artist("Test Artist", self.network) + + # Act / Assert + self.assertTrue(artist1 != artist2) + + def test_band_members(self): + # Arrange + artist = pylast.Artist("The Beatles", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.skip_if_lastfm_api_broken(band_members) + self.assertGreaterEqual(len(band_members), 4) + + def test_no_band_members(self): + # Arrange + artist = pylast.Artist("John Lennon", self.network) + + # Act + band_members = artist.get_band_members() + + # Assert + self.assertIsNone(band_members) + + def test_artist_get_correction(self): + # Arrange + artist = pylast.Artist("guns and roses", self.network) + + # Act + corrected_artist_name = artist.get_correction() + + # Assert + self.assertEqual(corrected_artist_name, "Guns N' Roses") + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_country.py b/tests/test_pylast_country.py new file mode 100755 index 0000000..7d9554e --- /dev/null +++ b/tests/test_pylast_country.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastCountry(PyLastTestCase): + + def test_country_is_hashable(self): + # Arrange + country = self.network.get_country("Italy") + + # Act/Assert + self.helper_is_thing_hashable(country) + + def test_countries(self): + # Arrange + country1 = pylast.Country("Italy", self.network) + country2 = pylast.Country("Finland", self.network) + + # Act + text = str(country1) + rep = repr(country1) + url = country1.get_url() + + # Assert + self.assertIn("Italy", rep) + self.assertIn("pylast.Country", rep) + self.assertEqual(text, "Italy") + self.assertTrue(country1 == country1) + self.assertTrue(country1 != country2) + self.assertEqual(url, "https://www.last.fm/place/italy") + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_library.py b/tests/test_pylast_library.py new file mode 100755 index 0000000..0df22da --- /dev/null +++ b/tests/test_pylast_library.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastLibrary(PyLastTestCase): + + def test_library_is_hashable(self): + # Arrange + library = pylast.Library(user=self.username, network=self.network) + + # Act/Assert + self.helper_is_thing_hashable(library) + + def test_cacheable_library(self): + # Arrange + library = pylast.Library(self.username, self.network) + + # Act/Assert + self.helper_validate_cacheable(library, "get_artists") + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_librefm.py b/tests/test_pylast_librefm.py index 3d2cb53..c403e2b 100755 --- a/tests/test_pylast_librefm.py +++ b/tests/test_pylast_librefm.py @@ -7,6 +7,7 @@ import unittest from flaky import flaky import pylast + from .test_pylast import load_secrets diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py new file mode 100755 index 0000000..553a7aa --- /dev/null +++ b/tests/test_pylast_network.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import time +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastNetwork(PyLastTestCase): + + def test_scrobble(self): + # Arrange + artist = "Test Artist" + title = "test title" + timestamp = self.unix_timestamp() + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.scrobble(artist=artist, title=title, timestamp=timestamp) + + # Assert + # limit=2 to ignore now-playing: + last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] + self.assertEqual(str(last_scrobble.track.artist), str(artist)) + self.assertEqual(str(last_scrobble.track.title), str(title)) + self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + + def test_update_now_playing(self): + # Arrange + artist = "Test Artist" + title = "test title" + album = "Test Album" + track_number = 1 + lastfm_user = self.network.get_user(self.username) + + # Act + self.network.update_now_playing( + artist=artist, title=title, album=album, track_number=track_number) + + # Assert + current_track = lastfm_user.get_now_playing() + self.assertIsNotNone(current_track) + self.assertEqual(str(current_track.title), "test title") + self.assertEqual(str(current_track.artist), "Test Artist") + + def test_invalid_xml(self): + # Arrange + # Currently causes PCDATA invalid Char value 25 + artist = "Blind Willie Johnson" + title = "It's nobody's fault but mine" + + # Act + search = self.network.search_for_track(artist, title) + total = search.get_total_result_count() + + # Assert + self.skip_if_lastfm_api_broken(total) + self.assertGreaterEqual(int(total), 0) + + def test_enable_rate_limiting(self): + # Arrange + self.assertFalse(self.network.is_rate_limited()) + + # Act + self.network.enable_rate_limit() + then = time.time() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + now = time.time() + + # Assert + self.assertTrue(self.network.is_rate_limited()) + self.assertGreaterEqual(now - then, 0.2) + + def test_disable_rate_limiting(self): + # Arrange + self.network.enable_rate_limit() + self.assertTrue(self.network.is_rate_limited()) + + # Act + self.network.disable_rate_limit() + # Make some network call, limit not applied first time + self.network.get_user(self.username) + # Make a second network call, limiting should be applied + self.network.get_top_artists() + + # Assert + self.assertFalse(self.network.is_rate_limited()) + + def test_lastfm_network_name(self): + # Act + name = str(self.network) + + # Assert + self.assertEqual(name, "Last.fm Network") + + def test_geo_get_top_artists(self): + # Arrange + # Act + artists = self.network.get_geo_top_artists( + country="United Kingdom", limit=1) + + # Assert + self.assertEqual(len(artists), 1) + self.assertIsInstance(artists[0], pylast.TopItem) + self.assertIsInstance(artists[0].item, pylast.Artist) + + def test_geo_get_top_tracks(self): + # Arrange + # Act + tracks = self.network.get_geo_top_tracks( + country="United Kingdom", location="Manchester", limit=1) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertIsInstance(tracks[0], pylast.TopItem) + self.assertIsInstance(tracks[0].item, pylast.Track) + + def test_network_get_top_artists_with_limit(self): + # Arrange + # Act + artists = self.network.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_network_get_top_tags_with_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + def test_network_get_top_tags_with_no_limit(self): + # Arrange + # Act + tags = self.network.get_top_tags() + + # Assert + self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) + + def test_network_get_top_tracks_with_limit(self): + # Arrange + # Act + tracks = self.network.get_top_tracks(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(tracks, pylast.Track) + + def test_country_top_tracks(self): + # Arrange + country = self.network.get_country("Croatia") + + # Act + things = country.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_country_network_top_tracks(self): + # Arrange + # Act + things = self.network.get_geo_top_tracks("Croatia", limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_tag_top_tracks(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + things = tag.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def test_album_data(self): + # Arrange + thing = self.network.get_album("Test Artist", "Test Album") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url() + + # Assert + self.assertEqual(stringed, "Test Artist - Test Album") + self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "https://www.last.fm/music/test%2bartist/test%2balbum", url) + + def test_track_data(self): + # Arrange + thing = self.network.get_track("Test Artist", "test title") + + # Act + stringed = str(thing) + repr = thing.__repr__() + title = thing.get_title() + name = thing.get_name() + playcount = thing.get_playcount() + url = thing.get_url(pylast.DOMAIN_FRENCH) + + # Assert + self.assertEqual(stringed, "Test Artist - test title") + self.assertIn("pylast.Track('Test Artist', 'test title',", repr) + self.assertEqual(title, "test title") + self.assertEqual(title, name) + self.assertIsInstance(playcount, int) + self.assertGreater(playcount, 1) + self.assertEqual( + "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) + + def test_country_top_artists(self): + # Arrange + country = self.network.get_country("Ukraine") + + # Act + artists = country.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_caching(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + self.network.enable_caching() + tags1 = user.get_top_tags(limit=1, cacheable=True) + tags2 = user.get_top_tags(limit=1, cacheable=True) + + # Assert + self.assertTrue(self.network.is_caching_enabled()) + self.assertEqual(tags1, tags2) + self.network.disable_caching() + self.assertFalse(self.network.is_caching_enabled()) + + def test_album_mbid(self): + # Arrange + mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" + + # Act + album = self.network.get_album_by_mbid(mbid) + album_mbid = album.get_mbid() + + # Assert + self.assertIsInstance(album, pylast.Album) + self.assertEqual(album.title.lower(), "test") + self.assertEqual(album_mbid, mbid) + + def test_artist_mbid(self): + # Arrange + mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" + + # Act + artist = self.network.get_artist_by_mbid(mbid) + + # Assert + self.assertIsInstance(artist, pylast.Artist) + self.assertEqual(artist.name, "MusicBrainz Test Artist") + + def test_track_mbid(self): + # Arrange + mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" + + # Act + track = self.network.get_track_by_mbid(mbid) + track_mbid = track.get_mbid() + + # Assert + self.assertIsInstance(track, pylast.Track) + self.assertEqual(track.title, "first") + self.assertEqual(track_mbid, mbid) + + def test_init_with_token(self): + # Arrange/Act + try: + pylast.LastFMNetwork( + api_key=self.__class__.secrets["api_key"], + api_secret=self.__class__.secrets["api_secret"], + token="invalid", + ) + except pylast.WSError as exc: + msg = str(exc) + + # Assert + self.assertEqual(msg, + "Unauthorized Token - This token has not been issued") + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_tag.py b/tests/test_pylast_tag.py new file mode 100755 index 0000000..d1a5f72 --- /dev/null +++ b/tests/test_pylast_tag.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastTag(PyLastTestCase): + + def test_tag_is_hashable(self): + # Arrange + tag = self.network.get_top_tags(limit=1)[0] + + # Act/Assert + self.helper_is_thing_hashable(tag) + + def test_tag_top_artists(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + artists = tag.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_tag_top_albums(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + albums = tag.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_tags(self): + # Arrange + tag1 = self.network.get_tag("blues") + tag2 = self.network.get_tag("rock") + + # Act + tag_repr = repr(tag1) + tag_str = str(tag1) + name = tag1.get_name(properly_capitalized=True) + url = tag1.get_url() + + # Assert + self.assertEqual("blues", tag_str) + self.assertIn("pylast.Tag", tag_repr) + self.assertIn("blues", tag_repr) + self.assertEqual("blues", name) + self.assertTrue(tag1 == tag1) + self.assertTrue(tag1 != tag2) + self.assertEqual(url, "https://www.last.fm/tag/blues") + + def test_tags_similar(self): + # Arrange + tag = self.network.get_tag("blues") + + # Act + similar = tag.get_similar() + + # Assert + self.skip_if_lastfm_api_broken(similar) + found = False + for tag in similar: + if tag.name == "delta blues": + found = True + break + self.assertTrue(found) + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_track.py b/tests/test_pylast_track.py new file mode 100755 index 0000000..9f446da --- /dev/null +++ b/tests/test_pylast_track.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import unittest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastTrack(PyLastTestCase): + + def test_love(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = self.network.get_track(artist, title) + lastfm_user = self.network.get_user(self.username) + + # Act + track.love() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + self.assertEqual(str(loved[0].track.artist), "Test Artist") + self.assertEqual(str(loved[0].track.title), "test title") + + def test_unlove(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + title = "test title" + track = pylast.Track(artist, title, self.network) + lastfm_user = self.network.get_user(self.username) + track.love() + + # Act + track.unlove() + + # Assert + loved = lastfm_user.get_loved_tracks(limit=1) + if len(loved): # OK to be empty but if not: + self.assertNotEqual(str(loved.track.artist), "Test Artist") + self.assertNotEqual(str(loved.track.title), "test title") + + def test_user_play_count_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) + + # Act + count = track.get_userplaycount() + + # Assert + self.assertGreaterEqual(count, 0) + + def test_user_loved_in_track_info(self): + # Arrange + artist = "Test Artist" + title = "test title" + track = pylast.Track( + artist=artist, title=title, + network=self.network, username=self.username) + + # Act + loved = track.get_userloved() + + # Assert + self.assertIsNotNone(loved) + self.assertIsInstance(loved, bool) + self.assertNotIsInstance(loved, str) + + def test_track_is_hashable(self): + # Arrange + artist = self.network.get_artist("Test Artist") + track = artist.get_top_tracks()[0].item + self.assertIsInstance(track, pylast.Track) + + # Act/Assert + self.helper_is_thing_hashable(track) + + def test_track_wiki_content(self): + # Arrange + track = pylast.Track("Test Artist", "test title", self.network) + + # Act + wiki = track.get_wiki_content() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + def test_track_wiki_summary(self): + # Arrange + track = pylast.Track("Test Artist", "test title", self.network) + + # Act + wiki = track.get_wiki_summary() + + # Assert + self.assertIsNotNone(wiki) + self.assertGreaterEqual(len(wiki), 1) + + +if __name__ == '__main__': + unittest.main(failfast=True) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py new file mode 100755 index 0000000..3f24baa --- /dev/null +++ b/tests/test_pylast_user.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python +""" +Integration (not unit) tests for pylast.py +""" +import os +import unittest + +import pytest + +import pylast + +from .test_pylast import PyLastTestCase + + +class TestPyLastUser(PyLastTestCase): + + def test_get_user_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + registered = user.get_registered() + + # Assert + # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp + if int(registered): + pytest.skip("Last.fm API is broken.") + + # Just check date because of timezones + self.assertIn(u"2002-11-20 ", registered) + + def test_get_user_unixtime_registration(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + unixtime_registered = user.get_unixtime_registered() + + # Assert + # Just check date because of timezones + self.assertEqual(unixtime_registered, u"1037793040") + + def test_get_genderless_user(self): + # Arrange + # Currently test_user has no gender set: + lastfm_user = self.network.get_user("test_user") + + # Act + gender = lastfm_user.get_gender() + + # Assert + self.assertIsNone(gender) + + def test_get_countryless_user(self): + # Arrange + # Currently test_user has no country set: + lastfm_user = self.network.get_user("test_user") + + # Act + country = lastfm_user.get_country() + + # Assert + self.assertIsNone(country) + + def test_user_equals_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user is None) + + # Assert + self.assertFalse(value) + + def test_user_not_equal_to_none(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + value = (lastfm_user is not None) + + # Assert + self.assertTrue(value) + + def test_now_playing_user_with_no_scrobbles(self): + # Arrange + # Currently test-account has no scrobbles: + user = self.network.get_user('test-account') + + # Act + current_track = user.get_now_playing() + + # Assert + self.assertIsNone(current_track) + + def test_love_limits(self): + # Arrange + # Currently test-account has at least 23 loved tracks: + user = self.network.get_user("test-user") + + # Act/Assert + self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) + self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) + self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + + def test_user_is_hashable(self): + # Arrange + user = self.network.get_user(self.username) + + # Act/Assert + self.helper_is_thing_hashable(user) + + # Commented out because (a) it'll take a long time and (b) it strangely + # fails due Last.fm's complaining of hitting the rate limit, even when + # limited to one call per second. The ToS allows 5 calls per second. + # def test_get_all_scrobbles(self): + # # Arrange + # lastfm_user = self.network.get_user("RJ") + # self.network.enable_rate_limit() # this is going to be slow... + + # # Act + # tracks = lastfm_user.get_recent_tracks(limit=None) + + # # Assert + # self.assertGreaterEqual(len(tracks), 0) + + def test_pickle(self): + # Arrange + import pickle + lastfm_user = self.network.get_user(self.username) + filename = str(self.unix_timestamp()) + ".pkl" + + # Act + with open(filename, "wb") as f: + pickle.dump(lastfm_user, f) + with open(filename, "rb") as f: + loaded_user = pickle.load(f) + os.remove(filename) + + # Assert + self.assertEqual(lastfm_user, loaded_user) + + def test_cacheable_user_artist_tracks(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act + result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) + result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) + result3 = lastfm_user.get_artist_tracks("Test Artist") + + # Assert + self.helper_validate_results(result1, result2, result3) + + def test_cacheable_user(self): + # Arrange + lastfm_user = self.network.get_authenticated_user() + + # Act/Assert + self.helper_validate_cacheable(lastfm_user, "get_friends") + self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") + self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") + + def test_user_get_top_tags_with_limit(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + tags = user.get_top_tags(limit=1) + + # Assert + self.skip_if_lastfm_api_broken(tags) + self.helper_only_one_thing_in_top_list(tags, pylast.Tag) + + def test_user_top_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + things = lastfm_user.get_top_tracks(limit=2) + + # Assert + self.helper_two_different_things_in_top_list(things, pylast.Track) + + def helper_assert_chart(self, chart, expected_type): + # Assert + self.assertIsNotNone(chart) + self.assertGreater(len(chart), 0) + self.assertIsInstance(chart[0], pylast.TopItem) + self.assertIsInstance(chart[0].item, expected_type) + + def helper_get_assert_charts(self, thing, date): + # Arrange + (from_date, to_date) = date + + # Act + artist_chart = thing.get_weekly_artist_charts(from_date, to_date) + if type(thing) is not pylast.Tag: + album_chart = thing.get_weekly_album_charts(from_date, to_date) + track_chart = thing.get_weekly_track_charts(from_date, to_date) + + # Assert + self.helper_assert_chart(artist_chart, pylast.Artist) + if type(thing) is not pylast.Tag: + self.helper_assert_chart(album_chart, pylast.Album) + self.helper_assert_chart(track_chart, pylast.Track) + + def helper_dates_valid(self, dates): + # Assert + self.assertGreaterEqual(len(dates), 1) + self.assertIsInstance(dates[0], tuple) + (start, end) = dates[0] + self.assertLess(start, end) + + def test_user_charts(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + dates = lastfm_user.get_weekly_chart_dates() + self.helper_dates_valid(dates) + + # Act/Assert + self.helper_get_assert_charts(lastfm_user, dates[0]) + + # Commented out to avoid spamming + # def test_share_spam(self): + # # Arrange + # users_to_spam = [TODO_ENTER_SPAMEES_HERE] + # spam_message = "Dig the krazee sound!" + # artist = self.network.get_top_artists(limit=1)[0].item + # track = artist.get_top_tracks(limit=1)[0].item + + # # Act + # artist.share(users_to_spam, spam_message) + # track.share(users_to_spam, spam_message) + + # Assert + # Check inbox for spam! + + # album/artist/track/user + + def test_user_top_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act + artists = lastfm_user.get_top_artists(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(artists, pylast.Artist) + + def test_user_top_albums(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + albums = user.get_top_albums(limit=1) + + # Assert + self.helper_only_one_thing_in_top_list(albums, pylast.Album) + + def test_user_tagged_artists(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["artisttagola"] + artist = self.network.get_artist("Test Artist") + artist.add_tags(tags) + + # Act + artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(artists, pylast.Artist) + + def test_user_tagged_albums(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["albumtagola"] + album = self.network.get_album("Test Artist", "Test Album") + album.add_tags(tags) + + # Act + albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(albums, pylast.Album) + + def test_user_tagged_tracks(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + tags = ["tracktagola"] + track = self.network.get_track("Test Artist", "test title") + track.add_tags(tags) + + # Act + tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) + + # Assert + self.helper_only_one_thing_in_list(tracks, pylast.Track) + + def test_user_subscriber(self): + # Arrange + subscriber = self.network.get_user("RJ") + non_subscriber = self.network.get_user("Test User") + + # Act + subscriber_is_subscriber = subscriber.is_subscriber() + non_subscriber_is_subscriber = non_subscriber.is_subscriber() + + # Assert + self.assertTrue(subscriber_is_subscriber) + self.assertFalse(non_subscriber_is_subscriber) + + def test_user_get_image(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + url = user.get_image() + + # Assert + self.assertTrue(url.startswith("https://")) + + def test_user_get_library(self): + # Arrange + user = self.network.get_user(self.username) + + # Act + library = user.get_library() + + # Assert + self.assertIsInstance(library, pylast.Library) + + def test_get_recent_tracks_from_to(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + + from datetime import datetime + start = datetime(2011, 7, 21, 15, 10) + end = datetime(2011, 7, 21, 15, 15) + import calendar + utc_start = calendar.timegm(start.utctimetuple()) + utc_end = calendar.timegm(end.utctimetuple()) + + # Act + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, + time_to=utc_end) + + # Assert + self.assertEqual(len(tracks), 1) + self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") + self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + + def test_tracks_notequal(self): + # Arrange + track1 = pylast.Track("Test Artist", "test title", self.network) + track2 = pylast.Track("Test Artist", "Test Track", self.network) + + # Act + # Assert + self.assertNotEqual(track1, track2) + + def test_track_id(self): + # Arrange + track = pylast.Track("Test Artist", "test title", self.network) + + # Act + id = track.get_id() + + # Assert + self.skip_if_lastfm_api_broken(id) + self.assertEqual(id, "14053327") + + def test_track_title_prop_caps(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + title = track.get_title(properly_capitalized=True) + + # Assert + self.assertEqual(title, "test title") + + def test_track_listener_count(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + count = track.get_listener_count() + + # Assert + self.assertGreater(count, 21) + + def test_album_tracks(self): + # Arrange + album = pylast.Album("Test Artist", "Test Release", self.network) + + # Act + tracks = album.get_tracks() + url = tracks[0].get_url() + + # Assert + self.assertIsInstance(tracks, list) + self.assertIsInstance(tracks[0], pylast.Track) + self.assertEqual(len(tracks), 4) + self.assertTrue(url.startswith("https://www.last.fm/music/test")) + + def test_track_eq_none_is_false(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "test title", self.network) + + # Act / Assert + self.assertFalse(track1 == track2) + + def test_track_ne_none_is_true(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "test title", self.network) + + # Act / Assert + self.assertTrue(track1 != track2) + + def test_track_get_correction(self): + # Arrange + track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) + + # Act + corrected_track_name = track.get_correction() + + # Assert + self.assertEqual(corrected_track_name, "Mr. Brownstone") + + def test_track_with_no_mbid(self): + # Arrange + track = pylast.Track("Static-X", "Set It Off", self.network) + + # Act + mbid = track.get_mbid() + + # Assert + self.assertEqual(mbid, None) + + +if __name__ == '__main__': + unittest.main(failfast=True) From 58b93c847e0e3ec71b1eff0b63a7d45e94005bc3 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 18 Oct 2017 08:57:39 +0300 Subject: [PATCH 342/768] WIP: More tests --- tests/test_pylast_network.py | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 553a7aa..04037f4 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -302,6 +302,69 @@ class TestPyLastNetwork(PyLastTestCase): self.assertEqual(msg, "Unauthorized Token - This token has not been issued") + def test_proxy(self): + # Arrange + host = "https://example.com" + port = 1234 + + # Act / Assert + self.network.enable_proxy(host, port) + self.assertTrue(self.network.is_proxy_enabled()) + self.assertEqual(self.network._get_proxy(), + ["https://example.com", 1234]) + + self.network.disable_proxy() + self.assertFalse(self.network.is_proxy_enabled()) + + def test_album_search(self): + # Arrange + album = "Nevermind" + + # Act + search = self.network.search_for_album(album) + results = search.get_next_page() + + # Assert + self.assertIsInstance(results, list) + self.assertIsInstance(results[0], pylast.Album) + + def test_artist_search(self): + # Arrange + artist = "Nirvana" + + # Act + search = self.network.search_for_artist(artist) + results = search.get_next_page() + + # Assert + self.assertIsInstance(results, list) + self.assertIsInstance(results[0], pylast.Artist) + + def test_tag_search(self): + # Arrange + tag = "rock" + + # Act + search = self.network.search_for_tag(tag) + results = search.get_next_page() + + # Assert + self.assertIsInstance(results, list) + self.assertIsInstance(results[0], pylast.Tag) + + def test_track_search(self): + # Arrange + artist = "Nirvana" + track = "Smells Like Teen Spirit" + + # Act + search = self.network.search_for_track(artist, track) + results = search.get_next_page() + + # Assert + self.assertIsInstance(results, list) + self.assertIsInstance(results[0], pylast.Track) + if __name__ == '__main__': unittest.main(failfast=True) From f70254b9473bc133e4138e1c4680df6c4ed3a72d Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 18 Oct 2017 22:31:15 +0300 Subject: [PATCH 343/768] Remove dead Last.fm tag search --- pylast/__init__.py | 27 --------------------------- tests/test_pylast_network.py | 12 ------------ 2 files changed, 39 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 333768b..0470a17 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -445,12 +445,6 @@ class _Network(object): return ArtistSearch(artist_name, self) - def search_for_tag(self, tag_name): - """Searches of a tag by its name. Returns a TagSearch object. - Use get_next_page() to retrieve sequences of results.""" - - return TagSearch(tag_name, self) - def search_for_track(self, artist_name, track_name): """Searches of a track by its name and its artist. Set artist to an empty string if not available. @@ -2729,27 +2723,6 @@ class ArtistSearch(_Search): return seq -class TagSearch(_Search): - """Search for a tag by tag name.""" - - def __init__(self, tag_name, network): - - _Search.__init__(self, "tag", {"tag": tag_name}, network) - - def get_next_page(self): - """Returns the next page of results as a sequence of Tag objects.""" - - master_node = self._retrieve_next_page() - - seq = [] - for node in master_node.getElementsByTagName("tag"): - tag = Tag(_extract(node, "name"), self.network) - tag.tag_count = _number(_extract(node, "count")) - seq.append(tag) - - return seq - - class TrackSearch(_Search): """ Search for a track by track title. If you don't want to narrow the results diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 04037f4..7ea6432 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -340,18 +340,6 @@ class TestPyLastNetwork(PyLastTestCase): self.assertIsInstance(results, list) self.assertIsInstance(results[0], pylast.Artist) - def test_tag_search(self): - # Arrange - tag = "rock" - - # Act - search = self.network.search_for_tag(tag) - results = search.get_next_page() - - # Assert - self.assertIsInstance(results, list) - self.assertIsInstance(results[0], pylast.Tag) - def test_track_search(self): # Arrange artist = "Nirvana" From f2b699a11f37e4dda26da4281e9ce9379ff1b1b4 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 18 Oct 2017 23:48:02 +0300 Subject: [PATCH 344/768] More tests --- tests/test_pylast_library.py | 31 ++++++++++++++++++++++++++ tests/test_pylast_librefm.py | 14 ++++++++++++ tests/test_pylast_user.py | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/tests/test_pylast_library.py b/tests/test_pylast_library.py index 0df22da..1e437f1 100755 --- a/tests/test_pylast_library.py +++ b/tests/test_pylast_library.py @@ -11,6 +11,26 @@ from .test_pylast import PyLastTestCase class TestPyLastLibrary(PyLastTestCase): + def test_repr(self): + # Arrange + library = pylast.Library(user=self.username, network=self.network) + + # Act + representation = repr(library) + + # Assert + self.assertTrue(representation.startswith("pylast.Library(")) + + def test_str(self): + # Arrange + library = pylast.Library(user=self.username, network=self.network) + + # Act + string = str(library) + + # Assert + self.assertTrue(string.endswith("'s Library")) + def test_library_is_hashable(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -25,6 +45,17 @@ class TestPyLastLibrary(PyLastTestCase): # Act/Assert self.helper_validate_cacheable(library, "get_artists") + def test_get_user(self): + # Arrange + library = pylast.Library(user=self.username, network=self.network) + user_to_get = self.network.get_user(self.username) + + # Act + library_user = library.get_user() + + # Assert + self.assertEqual(library_user, user_to_get) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_librefm.py b/tests/test_pylast_librefm.py index c403e2b..7c958cb 100755 --- a/tests/test_pylast_librefm.py +++ b/tests/test_pylast_librefm.py @@ -30,6 +30,20 @@ class TestPyLastWithLibreFm(unittest.TestCase): # Assert self.assertEqual(name, "Radiohead") + def test_repr(self): + # Arrange + secrets = load_secrets() + username = secrets["username"] + password_hash = secrets["password_hash"] + network = pylast.LibreFMNetwork( + password_hash=password_hash, username=username) + + # Act + representation = repr(network) + + # Assert + self.assertTrue(representation.startswith("pylast.LibreFMNetwork(")) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 3f24baa..36ef7bd 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -14,6 +14,49 @@ from .test_pylast import PyLastTestCase class TestPyLastUser(PyLastTestCase): + def test_repr(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + representation = repr(user) + + # Assert + self.assertTrue( + representation.startswith(representation), "pylast.User('RJ',") + + def test_str(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + string = str(user) + + # Assert + self.assertEqual(string, "RJ") + + def test_equality(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + not_a_user = self.network + + # Act / Assert + self.assertNotEqual(user, not_a_user) + + def test_get_name(self): + # Arrange + username = "RJ" + user = self.network.get_user(username) + + # Act + name = user.get_name(properly_capitalized=True) + + # Assert + self.assertEqual(name, "RJ") + def test_get_user_registration(self): # Arrange username = "RJ" From 2ef88dfd4c0c26b3af042d090352e07be0644f1b Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 00:22:06 +0300 Subject: [PATCH 345/768] Remove dead or broken Last.fm user functions --- pylast/__init__.py | 56 ---------------------------------------------- 1 file changed, 56 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0470a17..270ae69 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -97,9 +97,6 @@ IMAGES_ORDER_POPULARITY = "popularity" IMAGES_ORDER_DATE = "dateadded" -USER_MALE = 'Male' -USER_FEMALE = 'Female' - SCROBBLE_SOURCE_USER = "P" SCROBBLE_SOURCE_NON_PERSONALIZED_BROADCAST = "R" SCROBBLE_SOURCE_PERSONALIZED_BROADCAST = "E" @@ -2255,24 +2252,6 @@ class User(_BaseObject, _Chartable): return seq - def get_neighbours(self, limit=50, cacheable=True): - """Returns a list of the user's friends.""" - - params = self._get_params() - if limit: - params['limit'] = limit - - doc = self._request( - self.ws_prefix + '.getNeighbours', cacheable, params) - - seq = [] - names = _extract_all(doc, 'name') - - for name in names: - seq.append(User(name, self.network)) - - return seq - def get_now_playing(self): """ Returns the currently playing track, or None if nothing is playing. @@ -2350,20 +2329,6 @@ class User(_BaseObject, _Chartable): return seq - def get_id(self): - """Returns the user ID.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - return _extract(doc, "id") - - def get_language(self): - """Returns the language code of the language used by the user.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - return _extract(doc, "lang") - def get_country(self): """Returns the name of the country of the user.""" @@ -2376,27 +2341,6 @@ class User(_BaseObject, _Chartable): else: return Country(country, self.network) - def get_age(self): - """Returns the user's age.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - return _number(_extract(doc, "age")) - - def get_gender(self): - """Returns the user's gender. Either USER_MALE or USER_FEMALE.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - - value = _extract(doc, "gender") - - if value == 'm': - return USER_MALE - elif value == 'f': - return USER_FEMALE - - return None - def is_subscriber(self): """Returns whether the user is a subscriber or not. True or False.""" From 5fd9e4c8c5232c76b4879d26f9a166a105dd4f34 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 00:33:02 +0300 Subject: [PATCH 346/768] Remove dead or broken Last.fm user functions --- pylast/__init__.py | 33 --------------------------------- tests/test_pylast_user.py | 11 ----------- 2 files changed, 44 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 270ae69..a1a23d2 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2489,39 +2489,6 @@ class User(_BaseObject, _Chartable): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def compare_with_user(self, user, shared_artists_limit=None): - """ - Compare this user with another Last.fm user. - Returns a sequence: - (tasteometer_score, (shared_artist1, shared_artist2, ...)) - user: A User object or a username string/unicode object. - """ - - if isinstance(user, User): - user = user.get_name() - - params = self._get_params() - if shared_artists_limit: - params['limit'] = shared_artists_limit - params['type1'] = 'user' - params['type2'] = 'user' - params['value1'] = self.get_name() - params['value2'] = user - - doc = self._request('tasteometer.compare', False, params) - - score = _extract(doc, 'score') - - artists = doc.getElementsByTagName('artists')[0] - shared_artists_names = _extract_all(artists, 'name') - - shared_artists_seq = [] - - for name in shared_artists_names: - shared_artists_seq.append(Artist(name, self.network)) - - return (score, shared_artists_seq) - def get_image(self): """Returns the user's avatar.""" diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 36ef7bd..9121dbe 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -85,17 +85,6 @@ class TestPyLastUser(PyLastTestCase): # Just check date because of timezones self.assertEqual(unixtime_registered, u"1037793040") - def test_get_genderless_user(self): - # Arrange - # Currently test_user has no gender set: - lastfm_user = self.network.get_user("test_user") - - # Act - gender = lastfm_user.get_gender() - - # Assert - self.assertIsNone(gender) - def test_get_countryless_user(self): # Arrange # Currently test_user has no country set: From ebc5b29f5cdf719c1a35b4c48deb064a793874b6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 00:46:23 +0300 Subject: [PATCH 347/768] Remove dead or broken Last.fm user functions --- pylast/__init__.py | 44 --------------------------------------- tests/test_pylast_user.py | 17 --------------- 2 files changed, 61 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index a1a23d2..e74c247 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1074,37 +1074,6 @@ class _BaseObject(object): return seq - def share(self, users, message=None): - """ - Shares this (sends out recommendations). - Parameters: - * users [User|str,]: A list that can contain usernames, emails, - User objects, or all of them. - * message str: A message to include in the recommendation message. - Only for Artist/Track. - """ - - # Last.fm currently accepts a max of 10 recipient at a time - while len(users) > 10: - section = users[0:9] - users = users[9:] - self.share(section, message) - - user_names = [] - for user in users: - if isinstance(user, User): - user_names.append(user.get_name()) - else: - user_names.append(user) - - params = self._get_params() - recipients = ','.join(user_names) - params['recipient'] = recipients - if message: - params['message'] = message - - self._request(self.ws_prefix + '.share', False, params) - def get_wiki_published_date(self): """ Returns the summary of the wiki. @@ -2538,19 +2507,6 @@ class AuthenticatedUser(User): self.name = _extract(doc, "name") return self.name - def get_recommended_artists(self, limit=50, cacheable=False): - """ - Returns a sequence of Artist objects - if limit==None it will return all - """ - - seq = [] - for node in _collect_nodes( - limit, self, "user.getRecommendedArtists", cacheable): - seq.append(Artist(_extract(node, "name"), self.network)) - - return seq - class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 9121dbe..26876bf 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -256,23 +256,6 @@ class TestPyLastUser(PyLastTestCase): # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[0]) - # Commented out to avoid spamming - # def test_share_spam(self): - # # Arrange - # users_to_spam = [TODO_ENTER_SPAMEES_HERE] - # spam_message = "Dig the krazee sound!" - # artist = self.network.get_top_artists(limit=1)[0].item - # track = artist.get_top_tracks(limit=1)[0].item - - # # Act - # artist.share(users_to_spam, spam_message) - # track.share(users_to_spam, spam_message) - - # Assert - # Check inbox for spam! - - # album/artist/track/user - def test_user_top_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) From f9a8bf3daee5a4fd4fa59dd0318e7172eff9c5f9 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 00:55:20 +0300 Subject: [PATCH 348/768] More tests --- tests/test_pylast_album.py | 11 +++++++ tests/test_pylast_artist.py | 11 +++++++ tests/test_pylast_user.py | 64 ++++++++++++++++++++++++++++--------- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/tests/test_pylast_album.py b/tests/test_pylast_album.py index a93c27d..53581a5 100755 --- a/tests/test_pylast_album.py +++ b/tests/test_pylast_album.py @@ -99,6 +99,17 @@ class TestPyLastAlbum(PyLastTestCase): # Act / Assert self.assertTrue(album1 != album2) + def test_get_cover_image(self): + # Arrange + album = self.network.get_album("Test Artist", "Test Album") + + # Act + image = album.get_cover_image() + + # Assert + self.assertTrue(image.startswith("https://")) + self.assertTrue(image.endswith(".png")) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index 4fe15a2..bec278e 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -11,6 +11,17 @@ from .test_pylast import PyLastTestCase class TestPyLastArtist(PyLastTestCase): + def test_repr(self): + # Arrange + artist = pylast.Artist("Test Artist", self.network) + + # Act + representation = repr(artist) + + # Assert + self.assertTrue( + representation.startswith("pylast.Artist('Test Artist',")) + def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 26876bf..7613345 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -16,20 +16,17 @@ class TestPyLastUser(PyLastTestCase): def test_repr(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user = self.network.get_user("RJ") # Act representation = repr(user) # Assert - self.assertTrue( - representation.startswith(representation), "pylast.User('RJ',") + self.assertTrue(representation.startswith("pylast.User('RJ',")) def test_str(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user = self.network.get_user("RJ") # Act string = str(user) @@ -39,17 +36,27 @@ class TestPyLastUser(PyLastTestCase): def test_equality(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user_1a = self.network.get_user("RJ") + user_1b = self.network.get_user("RJ") + user_2 = self.network.get_user("Test User") not_a_user = self.network # Act / Assert - self.assertNotEqual(user, not_a_user) + self.assertEqual(user_1a, user_1b) + self.assertTrue(user_1a == user_1b) + self.assertFalse(user_1a != user_1b) + + self.assertNotEqual(user_1a, user_2) + self.assertTrue(user_1a != user_2) + self.assertFalse(user_1a == user_2) + + self.assertNotEqual(user_1a, not_a_user) + self.assertTrue(user_1a != not_a_user) + self.assertFalse(user_1a == not_a_user) def test_get_name(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user = self.network.get_user("RJ") # Act name = user.get_name(properly_capitalized=True) @@ -59,8 +66,7 @@ class TestPyLastUser(PyLastTestCase): def test_get_user_registration(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user = self.network.get_user("RJ") # Act registered = user.get_registered() @@ -75,8 +81,7 @@ class TestPyLastUser(PyLastTestCase): def test_get_user_unixtime_registration(self): # Arrange - username = "RJ" - user = self.network.get_user(username) + user = self.network.get_user("RJ") # Act unixtime_registered = user.get_unixtime_registered() @@ -458,6 +463,35 @@ class TestPyLastUser(PyLastTestCase): # Assert self.assertEqual(mbid, None) + def test_get_playcount(self): + # Arrange + user = self.network.get_user("RJ") + + # Act + playcount = user.get_playcount() + + # Assert + self.assertGreaterEqual(playcount, 128387) + + def test_get_image(self): + # Arrange + user = self.network.get_user("RJ") + + # Act / Assert + image = user.get_image() + + self.assertTrue(image.startswith("https://")) + self.assertTrue(image.endswith(".png")) + + def test_get_url(self): + # Arrange + user = self.network.get_user("RJ") + + # Act / Assert + url = user.get_url() + + self.assertEqual(url, "https://www.last.fm/user/rj") + if __name__ == '__main__': unittest.main(failfast=True) From 85bf9f9928272a0c3c95b964413c4a83677d1ac1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 23:36:00 +0300 Subject: [PATCH 349/768] Remove dead Last.fm track ban --- pylast/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index e74c247..06fe302 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2052,11 +2052,6 @@ class Track(_Opus): self._request(self.ws_prefix + '.unlove') - def ban(self): - """Ban this track from ever playing on the radio. """ - - self._request(self.ws_prefix + '.ban') - def get_similar(self): """ Returns similar tracks for this track on the network, From 8f3628a6386fe55c1271332010d4a3141a9bf30d Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 23:37:10 +0300 Subject: [PATCH 350/768] More tests --- tests/test_pylast_artist.py | 11 ++++++++ tests/test_pylast_track.py | 56 +++++++++++++++++++++++++++++++++++++ tests/test_pylast_user.py | 10 +++++++ 3 files changed, 77 insertions(+) diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index bec278e..75c951d 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -268,6 +268,17 @@ class TestPyLastArtist(PyLastTestCase): # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") + def test_get_userplaycount(self): + # Arrange + artist = pylast.Artist("John Lennon", self.network, + username=self.username) + + # Act + playcount = artist.get_userplaycount() + + # Assert + self.assertGreaterEqual(playcount, 0) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_track.py b/tests/test_pylast_track.py index 9f446da..61ef132 100755 --- a/tests/test_pylast_track.py +++ b/tests/test_pylast_track.py @@ -104,6 +104,62 @@ class TestPyLastTrack(PyLastTestCase): self.assertIsNotNone(wiki) self.assertGreaterEqual(len(wiki), 1) + def test_track_get_duration(self): + # Arrange + track = pylast.Track("Nirvana", "Lithium", self.network) + + # Act + duration = track.get_duration() + + # Assert + self.assertGreaterEqual(duration, 200000) + + def test_track_is_streamable(self): + # Arrange + track = pylast.Track("Nirvana", "Lithium", self.network) + + # Act + streamable = track.is_streamable() + + # Assert + self.assertFalse(streamable) + + def test_track_is_fulltrack_available(self): + # Arrange + track = pylast.Track("Nirvana", "Lithium", self.network) + + # Act + fulltrack_available = track.is_fulltrack_available() + + # Assert + self.assertFalse(fulltrack_available) + + def test_track_get_album(self): + # Arrange + track = pylast.Track("Nirvana", "Lithium", self.network) + + # Act + album = track.get_album() + print(album) + + # Assert + self.assertEqual(str(album), "Nirvana - Nevermind") + + def test_track_get_similar(self): + # Arrange + track = pylast.Track("Cher", "Believe", self.network) + + # Act + similar = track.get_similar() + + # Assert + found = False + for track in similar: + if str(track.item) == "Madonna - Vogue": + found = True + break + self.assertTrue(found) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 7613345..b64e0ee 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -101,6 +101,16 @@ class TestPyLastUser(PyLastTestCase): # Assert self.assertIsNone(country) + def test_user_get_country(self): + # Arrange + lastfm_user = self.network.get_user("RJ") + + # Act + country = lastfm_user.get_country() + + # Assert + self.assertEqual(str(country), "United Kingdom") + def test_user_equals_none(self): # Arrange lastfm_user = self.network.get_user(self.username) From 60c3b671bf618c82877294568d6c3d40a9326433 Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 19 Oct 2017 23:58:20 +0300 Subject: [PATCH 351/768] Be a good citizen https://snarky.ca/how-to-use-your-project-travis-to-help-test-python-itself/ --- .travis.yml | 2 ++ tox.ini | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e10837f..39948ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,6 +31,8 @@ matrix: env: TOXENV=pypy3 - python: pypy env: TOXENV=pypy + - python: 3.6-dev + env: TOXENV=py36dev allow_failures: - env: TOXENV=pypy - env: TOXENV=pypy3 diff --git a/tox.ini b/tox.ini index 33c73be..81ff0e1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py36, py35, py34, pypy, pypy3 +envlist = py27, py36, py35, py34, pypy, pypy3, py36dev recreate = False [testenv] From c0a25fbabe2cfc265613739eabe7e42427d2f8ec Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 20 Oct 2017 00:29:57 +0300 Subject: [PATCH 352/768] Remove dead Last.fm artist band members --- pylast/__init__.py | 11 ----------- tests/test_pylast_artist.py | 21 --------------------- 2 files changed, 32 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 06fe302..8a57787 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1738,17 +1738,6 @@ class Artist(_BaseObject, _Taggable): return self.network._get_url( domain_name, "artist") % {'artist': artist} - def get_band_members(self): - """Returns a list of band members or None if unknown.""" - - names = None - doc = self._request(self.ws_prefix + ".getInfo", True) - - for node in doc.getElementsByTagName("bandmembers"): - names = _extract_all(node, "name") - - return names - class Country(_BaseObject): """A country at Last.fm.""" diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index 75c951d..0d45f64 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -237,27 +237,6 @@ class TestPyLastArtist(PyLastTestCase): # Act / Assert self.assertTrue(artist1 != artist2) - def test_band_members(self): - # Arrange - artist = pylast.Artist("The Beatles", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.skip_if_lastfm_api_broken(band_members) - self.assertGreaterEqual(len(band_members), 4) - - def test_no_band_members(self): - # Arrange - artist = pylast.Artist("John Lennon", self.network) - - # Act - band_members = artist.get_band_members() - - # Assert - self.assertIsNone(band_members) - def test_artist_get_correction(self): # Arrange artist = pylast.Artist("guns and roses", self.network) From 00b6c0a61901411c09955e6d452aba2627b6b3a3 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 20 Oct 2017 00:32:40 +0300 Subject: [PATCH 353/768] Remove dead Last.fm tag.getSimilar --- pylast/__init__.py | 12 ------------ tests/test_pylast_tag.py | 16 ---------------- 2 files changed, 28 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 8a57787..f7d99dc 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1904,18 +1904,6 @@ class Tag(_BaseObject, _Chartable): return self.name - def get_similar(self): - """Returns the tags similar to this one, ordered by similarity. """ - - doc = self._request(self.ws_prefix + '.getSimilar', True) - - seq = [] - names = _extract_all(doc, 'name') - for name in names: - seq.append(Tag(name, self.network)) - - return seq - def get_top_albums(self, limit=None, cacheable=True): """Returns a list of the top albums.""" params = self._get_params() diff --git a/tests/test_pylast_tag.py b/tests/test_pylast_tag.py index d1a5f72..8d5440e 100755 --- a/tests/test_pylast_tag.py +++ b/tests/test_pylast_tag.py @@ -58,22 +58,6 @@ class TestPyLastTag(PyLastTestCase): self.assertTrue(tag1 != tag2) self.assertEqual(url, "https://www.last.fm/tag/blues") - def test_tags_similar(self): - # Arrange - tag = self.network.get_tag("blues") - - # Act - similar = tag.get_similar() - - # Assert - self.skip_if_lastfm_api_broken(similar) - found = False - for tag in similar: - if tag.name == "delta blues": - found = True - break - self.assertTrue(found) - if __name__ == '__main__': unittest.main(failfast=True) From 666181df506dd4be1075ab1f21a5ffaada7ae1f4 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 20 Oct 2017 00:39:02 +0300 Subject: [PATCH 354/768] Last.fm API broken but allow either yyyy-mm-dd or Unix timestamp --- tests/test_pylast_user.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index b64e0ee..3cc9969 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -72,12 +72,13 @@ class TestPyLastUser(PyLastTestCase): registered = user.get_registered() # Assert - # Last.fm API broken? Should be yyyy-mm-dd not Unix timestamp if int(registered): - pytest.skip("Last.fm API is broken.") - - # Just check date because of timezones - self.assertIn(u"2002-11-20 ", registered) + # Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp + self.assertEqual(registered, "1037793040") + else: + # Old way + # Just check date because of timezones + self.assertIn(u"2002-11-20 ", registered) def test_get_user_unixtime_registration(self): # Arrange From b55b40c3fe720a30887542838261eb2c1e241b5b Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 20 Oct 2017 00:45:17 +0300 Subject: [PATCH 355/768] Remove dead Last.fm album/track get ID --- pylast/__init__.py | 6 ------ tests/test_pylast_user.py | 13 ------------- 2 files changed, 19 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f7d99dc..df23ad6 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1429,12 +1429,6 @@ class _Opus(_BaseObject, _Taggable): return self.get_title(properly_capitalized) - def get_id(self): - """Returns the ID on the network.""" - - return _extract( - self._request(self.ws_prefix + ".getInfo", cacheable=True), "id") - def get_playcount(self): """Returns the number of plays on the network""" diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 3cc9969..53bb1fc 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -5,8 +5,6 @@ Integration (not unit) tests for pylast.py import os import unittest -import pytest - import pylast from .test_pylast import PyLastTestCase @@ -393,17 +391,6 @@ class TestPyLastUser(PyLastTestCase): # Assert self.assertNotEqual(track1, track2) - def test_track_id(self): - # Arrange - track = pylast.Track("Test Artist", "test title", self.network) - - # Act - id = track.get_id() - - # Assert - self.skip_if_lastfm_api_broken(id) - self.assertEqual(id, "14053327") - def test_track_title_prop_caps(self): # Arrange track = pylast.Track("test artist", "test title", self.network) From b55949536650a66e996a99428fead672c6ac93e7 Mon Sep 17 00:00:00 2001 From: hugovk Date: Fri, 20 Oct 2017 00:51:34 +0300 Subject: [PATCH 356/768] Assume no illegal XML and no more skipping broken Last.fm --- pylast/__init__.py | 15 --------------- tests/test_pylast.py | 5 ----- tests/test_pylast_network.py | 14 -------------- tests/test_pylast_user.py | 1 - 4 files changed, 35 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index df23ad6..3d1a219 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -22,7 +22,6 @@ from xml.dom import minidom, Node import collections -import re import hashlib import shelve import six @@ -108,18 +107,6 @@ SCROBBLE_MODE_LOVED = "L" SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" -# From http://boodebr.org/main/python/all-about-python-and-unicode#UNI_XML -RE_XML_ILLEGAL = (u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + - u'|' + - u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' - % - (unichr(0xd800), unichr(0xdbff), unichr(0xdc00), - unichr(0xdfff), unichr(0xd800), unichr(0xdbff), - unichr(0xdc00), unichr(0xdfff), unichr(0xd800), - unichr(0xdbff), unichr(0xdc00), unichr(0xdfff))) - -XML_ILLEGAL = re.compile(RE_XML_ILLEGAL) - # Python >3.4 and >2.7.9 has sane defaults SSL_CONTEXT = ssl.create_default_context() @@ -862,8 +849,6 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) - response_text = XML_ILLEGAL.sub("?", response_text) - self._check_response_for_errors(response_text) conn.close() return response_text diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 9279112..9ebbcc1 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -52,11 +52,6 @@ class PyLastTestCase(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) - def skip_if_lastfm_api_broken(self, value): - """Skip things not yet restored in Last.fm's broken API""" - if value is None or len(value) == 0: - pytest.skip("Last.fm API is broken.") - def helper_is_thing_hashable(self, thing): # Arrange things = set() diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 7ea6432..733a80e 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -47,20 +47,6 @@ class TestPyLastNetwork(PyLastTestCase): self.assertEqual(str(current_track.title), "test title") self.assertEqual(str(current_track.artist), "Test Artist") - def test_invalid_xml(self): - # Arrange - # Currently causes PCDATA invalid Char value 25 - artist = "Blind Willie Johnson" - title = "It's nobody's fault but mine" - - # Act - search = self.network.search_for_track(artist, title) - total = search.get_total_result_count() - - # Assert - self.skip_if_lastfm_api_broken(total) - self.assertGreaterEqual(int(total), 0) - def test_enable_rate_limiting(self): # Arrange self.assertFalse(self.network.is_rate_limited()) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 53bb1fc..66beb40 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -218,7 +218,6 @@ class TestPyLastUser(PyLastTestCase): tags = user.get_top_tags(limit=1) # Assert - self.skip_if_lastfm_api_broken(tags) self.helper_only_one_thing_in_top_list(tags, pylast.Tag) def test_user_top_tracks(self): From 68db2da2e96865f4bab185bafba49640d81aeaa6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 22 Oct 2017 23:17:27 +0300 Subject: [PATCH 357/768] pep8 is now pycodestyle https://github.com/PyCQA/pycodestyle/issues/466 --- setup.py | 5 +++-- tox.ini | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index 6d21e2a..2d9375c 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -from setuptools import setup, find_packages +from setuptools import find_packages, setup setup( @@ -7,7 +7,8 @@ setup( version="1.9.0", author="Amr Hassan ", install_requires=['six'], - tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'], + tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', + 'pyflakes'], description=("A Python interface to Last.fm and Libre.fm"), author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", diff --git a/tox.ini b/tox.ini index 33c73be..8300509 100644 --- a/tox.ini +++ b/tox.ini @@ -23,22 +23,22 @@ commands = {posargs} [testenv:py2lint] deps = - pep8 + pycodestyle pyflakes clonedigger commands = pyflakes pylast pyflakes tests - pep8 pylast - pep8 tests + pycodestyle pylast + pycodestyle tests ./clonedigger.sh [testenv:py3lint] deps = - pep8 + pycodestyle pyflakes commands = pyflakes pylast pyflakes tests - pep8 pylast - pep8 tests + pycodestyle pylast + pycodestyle tests From 52636b67643ef0066b90dd89f30b0e71fbaa3620 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 24 Oct 2017 00:04:05 +0300 Subject: [PATCH 358/768] Work around Last.fm's 'Namespace prefix opensearch on totalResults is not defined' XML error --- pylast/__init__.py | 2 +- tests/test_pylast_network.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 3d1a219..1db3d88 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2483,7 +2483,7 @@ class _Search(_BaseObject): doc = self._request(self._ws_prefix + ".search", True) - return _extract(doc, "opensearch:totalResults") + return _extract(doc, "totalResults") def _retrieve_page(self, page_index): """Returns the node of matches to be processed""" diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 733a80e..2906b92 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -339,6 +339,18 @@ class TestPyLastNetwork(PyLastTestCase): self.assertIsInstance(results, list) self.assertIsInstance(results[0], pylast.Track) + def test_search_get_total_result_count(self): + # Arrange + artist = "Nirvana" + track = "Smells Like Teen Spirit" + search = self.network.search_for_track(artist, track) + + # Act + total = search.get_total_result_count() + + # Assert + self.assertGreater(int(total), 10000) + if __name__ == '__main__': unittest.main(failfast=True) From cb1f7607316dcbf70324089fab96d456093c3ee6 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 24 Oct 2017 00:11:49 +0300 Subject: [PATCH 359/768] http -> https --- COPYING | 2 +- pylast/__init__.py | 2 +- tests/test_pylast_artist.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/COPYING b/COPYING index eec88ff..c4ff845 100644 --- a/COPYING +++ b/COPYING @@ -1,6 +1,6 @@ Apache License Version 2.0, January 2004 -http://www.apache.org/licenses/ +https://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION diff --git a/pylast/__init__.py b/pylast/__init__.py index 1db3d88..1dc7547 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -830,7 +830,7 @@ class _Request(object): try: conn.request( - method='POST', url="http://" + HOST_NAME + HOST_SUBDIR, + method='POST', url="https://" + HOST_NAME + HOST_SUBDIR, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index 0d45f64..66dbb49 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -213,7 +213,7 @@ class TestPyLastArtist(PyLastTestCase): name_cap = artist1.get_name(properly_capitalized=True) # Assert - self.assertIn("http", image) + self.assertIn("https", image) self.assertGreater(playcount, 1) self.assertTrue(artist1 != artist2) self.assertEqual(name.lower(), name_cap.lower()) From c601d2f365323290bbb94a62ddec0daab8fd4a39 Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 24 Oct 2017 00:54:34 +0300 Subject: [PATCH 360/768] Fix minor things from code inspection --- pylast/__init__.py | 2 +- setup.py | 4 ++-- tests/test_pylast_network.py | 9 +++++---- tests/test_pylast_user.py | 1 + 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 1dc7547..96ca64b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -244,7 +244,7 @@ class _Network(object): """ Returns an (API_KEY, API_SECRET, SESSION_KEY) tuple. """ - return (self.api_key, self.api_secret, self.session_key) + return self.api_key, self.api_secret, self.session_key def _delay_call(self): """ diff --git a/setup.py b/setup.py index 2d9375c..2a0de0d 100755 --- a/setup.py +++ b/setup.py @@ -8,8 +8,8 @@ setup( author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', - 'pyflakes'], - description=("A Python interface to Last.fm and Libre.fm"), + 'pyflakes', 'flaky'], + description="A Python interface to Last.fm and Libre.fm", author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", classifiers=[ diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 2906b92..23d2f20 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -174,7 +174,7 @@ class TestPyLastNetwork(PyLastTestCase): # Act stringed = str(thing) - repr = thing.__repr__() + rep = thing.__repr__() title = thing.get_title() name = thing.get_name() playcount = thing.get_playcount() @@ -182,7 +182,7 @@ class TestPyLastNetwork(PyLastTestCase): # Assert self.assertEqual(stringed, "Test Artist - Test Album") - self.assertIn("pylast.Album('Test Artist', 'Test Album',", repr) + self.assertIn("pylast.Album('Test Artist', 'Test Album',", rep) self.assertEqual(title, name) self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) @@ -195,7 +195,7 @@ class TestPyLastNetwork(PyLastTestCase): # Act stringed = str(thing) - repr = thing.__repr__() + rep = thing.__repr__() title = thing.get_title() name = thing.get_name() playcount = thing.get_playcount() @@ -203,7 +203,7 @@ class TestPyLastNetwork(PyLastTestCase): # Assert self.assertEqual(stringed, "Test Artist - test title") - self.assertIn("pylast.Track('Test Artist', 'test title',", repr) + self.assertIn("pylast.Track('Test Artist', 'test title',", rep) self.assertEqual(title, "test title") self.assertEqual(title, name) self.assertIsInstance(playcount, int) @@ -275,6 +275,7 @@ class TestPyLastNetwork(PyLastTestCase): def test_init_with_token(self): # Arrange/Act + msg = None try: pylast.LastFMNetwork( api_key=self.__class__.secrets["api_key"], diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 66beb40..1169f41 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -239,6 +239,7 @@ class TestPyLastUser(PyLastTestCase): def helper_get_assert_charts(self, thing, date): # Arrange + album_chart, track_chart = None, None (from_date, to_date) = date # Act From b2f58fde636cf422b356ba320fb19694ef4766bd Mon Sep 17 00:00:00 2001 From: hugovk Date: Tue, 24 Oct 2017 00:57:33 +0300 Subject: [PATCH 361/768] Remove commented-out, broken downloads badge --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8e55b29..87e259e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) - [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) [![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) From dc5e0ce8435e6c53f425bb39b3951d519e396e85 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 28 Oct 2017 09:56:12 +0300 Subject: [PATCH 362/768] Add supported Python versions badge [CI skip] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 87e259e..2af6559 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) [![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) From ec647d181cdad1b483debd315d44280f80005ce2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 28 Oct 2017 10:00:14 +0300 Subject: [PATCH 363/768] Update tests info [CI skip] --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2af6559..697ae2f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ pyLast A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). -Try using the pydoc utility for help on usage or see [test_pylast.py](tests/test_pylast.py) for examples. +Use the pydoc utility for help on usage or see [tests/](tests/) for examples. Installation ------------ @@ -74,12 +74,12 @@ track.add_tags(("awesome", "favorite")) # to get more help about anything and see examples of how it works ``` -More examples in hugovk/lastfm-tools and [test_pylast.py](test_pylast.py). +More examples in hugovk/lastfm-tools and [tests/](tests/). Testing ------- -[tests/test_pylast.py](tests/test_pylast.py) contains integration tests with Last.fm, and plenty of code examples. Unit tests are also in the [tests/](tests/) directory. +The [tests/](tests/) directory contains integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: From 79fa0c6f9d57375fcb412b9d75f5aefb51ad275c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 28 Oct 2017 10:01:47 +0300 Subject: [PATCH 364/768] pytest is recommended entry point --- README.md | 6 +++--- tox.ini | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 697ae2f..53b5d12 100644 --- a/README.md +++ b/README.md @@ -93,17 +93,17 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh pip install pytest flaky mock -py.test +pytest ``` Or run just one test case: ```sh -py.test -k test_scrobble +pytest -k test_scrobble ``` To run with coverage: ```sh -py.test -v --cov pylast --cov-report term-missing +pytest -v --cov pylast --cov-report term-missing coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html diff --git a/tox.ini b/tox.ini index 8300509..6347575 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ deps = ipdb pytest-cov flaky -commands = py.test -v -s -W all --cov pylast --cov-report term-missing {posargs} +commands = pytest -v -s -W all --cov pylast --cov-report term-missing {posargs} [testenv:venv] deps = ipdb From ae35af7e3bad5a185e0f26f712a3cf7fb008f9fe Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 29 Oct 2017 00:42:50 +0300 Subject: [PATCH 365/768] Release 2.0.0 --- pylast/__init__.py | 2 +- setup.cfg | 5 +++++ setup.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 setup.cfg diff --git a/pylast/__init__.py b/pylast/__init__.py index 96ca64b..d91033a 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '1.9.0' +__version__ = '2.0.0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk, ' '2017 Mice Pápai') diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..472423d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +universal = 1 + +[metadata] +license_file = COPYING diff --git a/setup.py b/setup.py index 2a0de0d..9090cff 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="pylast", - version="1.9.0", + version="2.0.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From 121e8a27c197bfd9e014d925dfe9eb721f5e8d06 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 29 Oct 2017 00:47:42 +0300 Subject: [PATCH 366/768] Start new release cycle --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index d91033a..40b9f07 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '2.0.0' +__version__ = '2.1.0.dev0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk, ' '2017 Mice Pápai') diff --git a/setup.py b/setup.py index 9090cff..2a6ae2a 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="pylast", - version="2.0.0", + version="2.1.0.dev0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From 825432b77f128bab9ac98ba37c6b8de84ff94595 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 29 Oct 2017 00:48:56 +0300 Subject: [PATCH 367/768] Update with thanks to https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/ --- RELEASING.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index af42567..778cdbd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,28 +1,38 @@ # Release Checklist -* [ ] Get [master to the appropriate code release state](https://github.com/pylast/pylast/compare/master...develop?expand=1). [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Update version in `pylast/__init__.py` and `setup.py` and commit: +* [ ] Get [master to the appropriate code release state](https://github.com/pylast/pylast/compare/master...develop?expand=1&title=Merge%20develop%20into%20master). [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. +* [ ] Remove `.dev0` suffix from version in `pylast/__init__.py` and `setup.py` and commit: ```bash git checkout master edit pylast/__init__.py setup.py git add pylast/__init__.py setup.py -git commit -m "Release 1.8.0" +git commit -m "Release 2.0.0" ``` * [ ] Tag the last commit with the version number: ```bash -git tag -a 1.8.0 -m "Release 1.8.0" +git tag -a 2.0.0 -m "Release 2.0.0" ``` * [ ] Create a distribution and release on PyPI: ```bash -python setup.py sdist --format=gztar -twine upload dist/pylast-1.8.0.tar.gz +pip install -U pip setuptools wheel twine keyring +rm -rf build +python setup.py sdist --format=gztar bdist_wheel +twine upload -r pypi dist/pylast-2.0.0* ``` * [ ] Check installation: `pip install -U pylast` * [ ] Push: `git push` * [ ] Push tags: `git push --tags` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "1.8.0" - * Title: "Release 1.8.0" + * Tag: Pick existing tag "2.0.0" + * Title: "Release 2.0.0" +* [ ] Increment version and append `.dev0` in `pylast/__init__.py` and `setup.py` and commit: +```bash +git checkout master +edit pylast/__init__.py setup.py +git add pylast/__init__.py setup.py +git commit -m "Start new release cycle" +git push +``` * [ ] Update develop branch from master: ```bash git checkout develop From 0d98c3590f2448225b35bf44e5baca24e7ef319d Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 2 Nov 2017 01:09:46 +0100 Subject: [PATCH 368/768] Added parameter to retrieve higher resolution user avatars --- pylast/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 40b9f07..d02d5bc 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2409,12 +2409,19 @@ class User(_BaseObject, _Chartable): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def get_image(self): - """Returns the user's avatar.""" + def get_image(self, size=COVER_EXTRA_LARGE): + """ + Returns the user's avatar + size can be one of: + COVER_EXTRA_LARGE + COVER_LARGE + COVER_MEDIUM + COVER_SMALL + """ doc = self._request(self.ws_prefix + ".getInfo", True) - return _extract(doc, "image") + return _extract_all(doc, "image")[size] def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the url of the user page on the network. From 35f67a09cb9ec0cd581e4642789130f6f3eabe90 Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 2 Nov 2017 16:31:41 +0100 Subject: [PATCH 369/768] Replaced deprecated COVER_ constants with SIZE_ --- pylast/__init__.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index d02d5bc..8602734 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -86,11 +86,11 @@ DOMAIN_RUSSIAN = 9 DOMAIN_JAPANESE = 10 DOMAIN_CHINESE = 11 -COVER_SMALL = 0 -COVER_MEDIUM = 1 -COVER_LARGE = 2 -COVER_EXTRA_LARGE = 3 -COVER_MEGA = 4 +SIZE_SMALL = COVER_SMALL = 0 +SIZE_MEDIUM = COVER_MEDIUM = 1 +SIZE_LARGE = COVER_LARGE = 2 +SIZE_EXTRA_LARGE = COVER_EXTRA_LARGE = 3 +SIZE_MEGA = COVER_MEGA = 4 IMAGES_ORDER_POPULARITY = "popularity" IMAGES_ORDER_DATE = "dateadded" @@ -1468,14 +1468,14 @@ class Album(_Opus): def __init__(self, artist, title, network, username=None): super(Album, self).__init__(artist, title, network, "album", username) - def get_cover_image(self, size=COVER_EXTRA_LARGE): + def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ Returns a uri to the cover image size can be one of: - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL + SIZE_EXTRA_LARGE + SIZE_LARGE + SIZE_MEDIUM + SIZE_SMALL """ return _extract_all( @@ -1575,15 +1575,15 @@ class Artist(_BaseObject, _Taggable): return _extract( self._request(self.ws_prefix + ".getCorrection"), "name") - def get_cover_image(self, size=COVER_MEGA): + def get_cover_image(self, size=SIZE_MEGA): """ Returns a uri to the cover image size can be one of: - COVER_MEGA - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL + SIZE_MEGA + SIZE_EXTRA_LARGE + SIZE_LARGE + SIZE_MEDIUM + SIZE_SMALL """ return _extract_all( @@ -2409,14 +2409,14 @@ class User(_BaseObject, _Chartable): return self._get_things( "getTopTracks", "track", Track, params, cacheable) - def get_image(self, size=COVER_EXTRA_LARGE): + def get_image(self, size=SIZE_EXTRA_LARGE): """ Returns the user's avatar size can be one of: - COVER_EXTRA_LARGE - COVER_LARGE - COVER_MEDIUM - COVER_SMALL + SIZE_EXTRA_LARGE + SIZE_LARGE + SIZE_MEDIUM + SIZE_SMALL """ doc = self._request(self.ws_prefix + ".getInfo", True) From 18708393af15ec3412c91c83ac5bcdca8f6342f5 Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 2 Nov 2017 17:04:24 +0100 Subject: [PATCH 370/768] Added depreciation comment, harmonized image sizes --- pylast/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 8602734..fc49fbc 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -86,6 +86,7 @@ DOMAIN_RUSSIAN = 9 DOMAIN_JAPANESE = 10 DOMAIN_CHINESE = 11 +# COVER_X is deprecated since 2.1.0 and will be removed in a future version SIZE_SMALL = COVER_SMALL = 0 SIZE_MEDIUM = COVER_MEDIUM = 1 SIZE_LARGE = COVER_LARGE = 2 @@ -1575,7 +1576,7 @@ class Artist(_BaseObject, _Taggable): return _extract( self._request(self.ws_prefix + ".getCorrection"), "name") - def get_cover_image(self, size=SIZE_MEGA): + def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ Returns a uri to the cover image size can be one of: From 128afc78623024c8691622a707e28a64fac80579 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 2 Nov 2017 19:03:42 +0200 Subject: [PATCH 371/768] How to install latest development version [CI skip] --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 53b5d12..da7f7a8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ Installation Install via pip: pip install pylast + +Install latest development version: + + pip install -U git+https://github.com/pylast/pylast.git + +Or from requirements.txt: + + -e git://github.com/pylast/pylast.git#egg=pylast Note: From 36ed30cc2bad5c244c72f3f6391dbde0289c9d7a Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 2 Nov 2017 18:41:15 +0100 Subject: [PATCH 372/768] Return unix timestamp as int --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fc49fbc..de89377 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2288,8 +2288,8 @@ class User(_BaseObject, _Chartable): doc = self._request(self.ws_prefix + ".getInfo", True) - return doc.getElementsByTagName( - "registered")[0].getAttribute("unixtime") + return int(doc.getElementsByTagName( + "registered")[0].getAttribute("unixtime")) def get_tagged_albums(self, tag, limit=None, cacheable=True): """Returns the albums tagged by a user.""" From 8f515d87795bf2c2c8399ee5dbbc37b696eebf93 Mon Sep 17 00:00:00 2001 From: Philip Trauner Date: Thu, 2 Nov 2017 19:24:45 +0100 Subject: [PATCH 373/768] Fixed test in accordance to 36ed30cc2bad5c244c72f3f6391dbde0289c9d7a --- tests/test_pylast_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 1169f41..40be3b8 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -87,7 +87,7 @@ class TestPyLastUser(PyLastTestCase): # Assert # Just check date because of timezones - self.assertEqual(unixtime_registered, u"1037793040") + self.assertEqual(unixtime_registered, 1037793040) def test_get_countryless_user(self): # Arrange From 762167345273039b2028a09e3dc5fd5faf2fd095 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 15 Nov 2017 15:10:31 +0200 Subject: [PATCH 374/768] Require Python 2.7 or 3.4+ --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 2a6ae2a..99fa123 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=('tests*',)), license="Apache2" From d3f6e65fa535f0735ef9d35147bdd2d25dbb53a0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Dec 2017 20:33:56 +0200 Subject: [PATCH 375/768] Update release checklist to remove develop branch [CI skip] --- RELEASING.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 778cdbd..4878085 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Release Checklist -* [ ] Get [master to the appropriate code release state](https://github.com/pylast/pylast/compare/master...develop?expand=1&title=Merge%20develop%20into%20master). [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. +* [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. * [ ] Remove `.dev0` suffix from version in `pylast/__init__.py` and `setup.py` and commit: ```bash git checkout master @@ -33,9 +33,3 @@ git add pylast/__init__.py setup.py git commit -m "Start new release cycle" git push ``` -* [ ] Update develop branch from master: -```bash -git checkout develop -git merge master --ff-only -git push -``` From 9da36d15d7ea5d93a14985a4fe4b33e99687bceb Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Dec 2017 20:48:15 +0200 Subject: [PATCH 376/768] Fix case-sensitive tests --- tests/test_pylast_network.py | 10 +++++----- tests/test_pylast_track.py | 4 ++-- tests/test_pylast_user.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_pylast_network.py b/tests/test_pylast_network.py index 23d2f20..f3d8cb8 100755 --- a/tests/test_pylast_network.py +++ b/tests/test_pylast_network.py @@ -14,7 +14,7 @@ class TestPyLastNetwork(PyLastTestCase): def test_scrobble(self): # Arrange - artist = "Test Artist" + artist = "test artist" title = "test title" timestamp = self.unix_timestamp() lastfm_user = self.network.get_user(self.username) @@ -25,8 +25,8 @@ class TestPyLastNetwork(PyLastTestCase): # Assert # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] - self.assertEqual(str(last_scrobble.track.artist), str(artist)) - self.assertEqual(str(last_scrobble.track.title), str(title)) + self.assertEqual(str(last_scrobble.track.artist).lower(), artist) + self.assertEqual(str(last_scrobble.track.title).lower(), title) self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) def test_update_now_playing(self): @@ -44,8 +44,8 @@ class TestPyLastNetwork(PyLastTestCase): # Assert current_track = lastfm_user.get_now_playing() self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title), "test title") - self.assertEqual(str(current_track.artist), "Test Artist") + self.assertEqual(str(current_track.title).lower(), "test title") + self.assertEqual(str(current_track.artist).lower(), "test artist") def test_enable_rate_limiting(self): # Arrange diff --git a/tests/test_pylast_track.py b/tests/test_pylast_track.py index 61ef132..7b0c99a 100755 --- a/tests/test_pylast_track.py +++ b/tests/test_pylast_track.py @@ -23,8 +23,8 @@ class TestPyLastTrack(PyLastTestCase): # Assert loved = lastfm_user.get_loved_tracks(limit=1) - self.assertEqual(str(loved[0].track.artist), "Test Artist") - self.assertEqual(str(loved[0].track.title), "test title") + self.assertEqual(str(loved[0].track.artist).lower(), "test artist") + self.assertEqual(str(loved[0].track.title).lower(), "test title") def test_unlove(self): # Arrange diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 40be3b8..64494b0 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -399,7 +399,7 @@ class TestPyLastUser(PyLastTestCase): title = track.get_title(properly_capitalized=True) # Assert - self.assertEqual(title, "test title") + self.assertEqual(title, "Test Title") def test_track_listener_count(self): # Arrange From 038ab141d365d7540a0a0a2295c85e6bce963566 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Dec 2017 21:37:39 +0200 Subject: [PATCH 377/768] develop -> master [CI skip] --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index da7f7a8..50b9a89 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ pyLast ====== -[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=develop)](https://travis-ci.org/pylast/pylast) [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.python.org/pypi/pylast/) -[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/develop/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) -[![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=develop)](https://coveralls.io/github/pylast/pylast?branch=develop) -[![Code health](https://landscape.io/github/pylast/pylast/develop/landscape.svg)](https://landscape.io/github/hugovk/pylast/develop) +[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) +[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) +[![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) +[![Code health](https://landscape.io/github/pylast/pylast/master/landscape.svg)](https://landscape.io/github/hugovk/pylast/master) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). From 9abb5884b7a2cc735be5600b620eb79a126002c8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Dec 2017 22:54:12 +0200 Subject: [PATCH 378/768] Update python_requires to 2.7.10+ Update python_requires to specify the exact x.y.z 2.7.10 version required. (From HTTPS changes in #178.) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 99fa123..6b164c6 100755 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires='>=2.7.10, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=('tests*',)), license="Apache2" From 348d2ce7ba2ae3224abc5cb51e460b0a9a4dcfb5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 4 Jan 2018 16:58:37 +0200 Subject: [PATCH 379/768] Update year --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index de89377..4ba99a4 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4,7 +4,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2017 hugovk +# Copyright 2013-2018 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -33,7 +33,7 @@ import xml.dom __version__ = '2.1.0.dev0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' -__copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2017 hugovk, ' +__copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' '2017 Mice Pápai') __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' From edeec178c581c512199eb69bd8869b5a05d45732 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 4 Jan 2018 17:04:47 +0200 Subject: [PATCH 380/768] Make checklist more copy/pastable [CI skip] --- RELEASING.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 4878085..5e22fb7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,34 +1,40 @@ # Release Checklist * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Remove `.dev0` suffix from version in `pylast/__init__.py` and `setup.py` and commit: +* [ ] Remove `.dev0` suffix from version in `pylast/__init__.py` and `setup.py`: ```bash git checkout master edit pylast/__init__.py setup.py -git add pylast/__init__.py setup.py -git commit -m "Release 2.0.0" ``` -* [ ] Tag the last commit with the version number: +* [ ] Commit and tag with the version number: ```bash -git tag -a 2.0.0 -m "Release 2.0.0" +git add pylast/__init__.py setup.py +git commit -m "Release 2.1.0" +git tag -a 2.1.0 -m "Release 2.1.0" ``` * [ ] Create a distribution and release on PyPI: ```bash pip install -U pip setuptools wheel twine keyring rm -rf build python setup.py sdist --format=gztar bdist_wheel -twine upload -r pypi dist/pylast-2.0.0* +twine upload -r pypi dist/pylast-2.1.0* ``` * [ ] Check installation: `pip install -U pylast` -* [ ] Push: `git push` -* [ ] Push tags: `git push --tags` +* [ ] Push commits and tags: + ```bash +git push +git push --tags +``` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "2.0.0" - * Title: "Release 2.0.0" -* [ ] Increment version and append `.dev0` in `pylast/__init__.py` and `setup.py` and commit: + * Tag: Pick existing tag "2.1.0" + * Title: "Release 2.1.0" +* [ ] Increment version and append `.dev0` in `pylast/__init__.py` and `setup.py`: ```bash git checkout master edit pylast/__init__.py setup.py +``` +* [ ] Commit and push: +```bash git add pylast/__init__.py setup.py git commit -m "Start new release cycle" git push From 22e03705c496ca5e7febe28d7cbc002c91ab2d0f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Jan 2018 10:37:01 +0200 Subject: [PATCH 381/768] Passing test for limit=1 or 50, failing test for 100 --- tests/test_pylast_artist.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index 66dbb49..658f1d3 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -86,6 +86,42 @@ class TestPyLastArtist(PyLastTestCase): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) + def test_artist_top_albums_limit_1(self): + # Arrange + limit = 1 + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums(limit=limit) + + # Assert + self.assertEqual(len(things), 1) + + def test_artist_top_albums_limit_50(self): + # Arrange + limit = 50 + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums(limit=limit) + + # Assert + self.assertEqual(len(things), 50) + + def test_artist_top_albums_limit_100(self): + # Arrange + limit = 100 + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums(limit=limit) + + # Assert + self.assertEqual(len(things), 100) + def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") From 309b156fca6811225a5ad7e027e69e5aa9e4df3d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 11 Jan 2018 11:36:14 +0200 Subject: [PATCH 382/768] Use paging to get all 'limit' items for Artist.get_top_albums (and others using _get_things) --- pylast/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 4ba99a4..3252c96 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1046,11 +1046,14 @@ class _BaseObject(object): self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" - doc = self._request( - self.ws_prefix + "." + method, cacheable, params) - + limit = params.get("limit", 1) seq = [] - for node in doc.getElementsByTagName(thing): + for node in _collect_nodes( + limit, + self, + self.ws_prefix + "." + method, + cacheable, + params): title = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) From d24100f2db12e10a504741f4a52124e68d276e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mice=20P=C3=A1pai?= Date: Fri, 5 May 2017 10:51:26 +0200 Subject: [PATCH 383/768] Fix serious cache key lookup performance problem Checking if a request is cached or not took ~6sec / item vs <0.01sec now, because __contains__ wasn't definied for the _ShelfCacheBackend class and it iterated over each cache key on every check --- pylast/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index 3252c96..46e8b62 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -713,6 +713,10 @@ class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None): self.shelf = shelve.open(file_path) + self.cache_keys = set(self.shelf.keys()) + + def __contains__(self, key): + return key in self.cache_keys def __iter__(self): return iter(self.shelf.keys()) @@ -721,6 +725,7 @@ class _ShelfCacheBackend(object): return self.shelf[key] def set_xml(self, key, xml_string): + self.cache_keys.add(key) self.shelf[key] = xml_string From 2389f745e1d5c8a559433d93e5341173ca3f95a1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 14 Jan 2018 20:01:42 +0200 Subject: [PATCH 384/768] Release 2.1.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 46e8b62..d8539ec 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '2.1.0.dev0' +__version__ = '2.1.0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' '2017 Mice Pápai') diff --git a/setup.py b/setup.py index 6b164c6..409380f 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="pylast", - version="2.1.0.dev0", + version="2.1.0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From d1ea097cd7f53eac6e26969b01fa77a5cf9f7fcb Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 14 Jan 2018 20:05:48 +0200 Subject: [PATCH 385/768] Start new release cycle --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index d8539ec..2fd0f12 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '2.1.0' +__version__ = '2.2.0.dev0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' '2017 Mice Pápai') diff --git a/setup.py b/setup.py index 409380f..2e73407 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup setup( name="pylast", - version="2.1.0", + version="2.2.0.dev0", author="Amr Hassan ", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From 2703b6b3854f6b1952ca55c3e9d16b091e8c74a9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 27 Jan 2018 12:59:20 +0200 Subject: [PATCH 386/768] Use more helpful asserts, remove redundant --- tests/test_pylast_album.py | 4 ++-- tests/test_pylast_artist.py | 6 +++--- tests/test_pylast_country.py | 4 ++-- tests/test_pylast_tag.py | 4 ++-- tests/test_pylast_user.py | 14 +++----------- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/test_pylast_album.py b/tests/test_pylast_album.py index 53581a5..2c7f369 100755 --- a/tests/test_pylast_album.py +++ b/tests/test_pylast_album.py @@ -89,7 +89,7 @@ class TestPyLastAlbum(PyLastTestCase): album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert - self.assertFalse(album1 == album2) + self.assertNotEqual(album1, album2) def test_album_ne_none_is_true(self): # Arrange @@ -97,7 +97,7 @@ class TestPyLastAlbum(PyLastTestCase): album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert - self.assertTrue(album1 != album2) + self.assertNotEqual(album1, album2) def test_get_cover_image(self): # Arrange diff --git a/tests/test_pylast_artist.py b/tests/test_pylast_artist.py index 658f1d3..58612ea 100755 --- a/tests/test_pylast_artist.py +++ b/tests/test_pylast_artist.py @@ -251,7 +251,7 @@ class TestPyLastArtist(PyLastTestCase): # Assert self.assertIn("https", image) self.assertGreater(playcount, 1) - self.assertTrue(artist1 != artist2) + self.assertNotEqual(artist1, artist2) self.assertEqual(name.lower(), name_cap.lower()) self.assertEqual(url, "https://www.last.fm/music/radiohead") self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") @@ -263,7 +263,7 @@ class TestPyLastArtist(PyLastTestCase): artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert - self.assertFalse(artist1 == artist2) + self.assertNotEqual(artist1, artist2) def test_artist_ne_none_is_true(self): # Arrange @@ -271,7 +271,7 @@ class TestPyLastArtist(PyLastTestCase): artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert - self.assertTrue(artist1 != artist2) + self.assertNotEqual(artist1, artist2) def test_artist_get_correction(self): # Arrange diff --git a/tests/test_pylast_country.py b/tests/test_pylast_country.py index 7d9554e..4f6b25f 100755 --- a/tests/test_pylast_country.py +++ b/tests/test_pylast_country.py @@ -32,8 +32,8 @@ class TestPyLastCountry(PyLastTestCase): self.assertIn("Italy", rep) self.assertIn("pylast.Country", rep) self.assertEqual(text, "Italy") - self.assertTrue(country1 == country1) - self.assertTrue(country1 != country2) + self.assertEqual(country1, country1) + self.assertNotEqual(country1, country2) self.assertEqual(url, "https://www.last.fm/place/italy") diff --git a/tests/test_pylast_tag.py b/tests/test_pylast_tag.py index 8d5440e..e56aac5 100755 --- a/tests/test_pylast_tag.py +++ b/tests/test_pylast_tag.py @@ -54,8 +54,8 @@ class TestPyLastTag(PyLastTestCase): self.assertIn("pylast.Tag", tag_repr) self.assertIn("blues", tag_repr) self.assertEqual("blues", name) - self.assertTrue(tag1 == tag1) - self.assertTrue(tag1 != tag2) + self.assertEqual(tag1, tag1) + self.assertNotEqual(tag1, tag2) self.assertEqual(url, "https://www.last.fm/tag/blues") diff --git a/tests/test_pylast_user.py b/tests/test_pylast_user.py index 64494b0..ad12feb 100755 --- a/tests/test_pylast_user.py +++ b/tests/test_pylast_user.py @@ -41,16 +41,8 @@ class TestPyLastUser(PyLastTestCase): # Act / Assert self.assertEqual(user_1a, user_1b) - self.assertTrue(user_1a == user_1b) - self.assertFalse(user_1a != user_1b) - self.assertNotEqual(user_1a, user_2) - self.assertTrue(user_1a != user_2) - self.assertFalse(user_1a == user_2) - self.assertNotEqual(user_1a, not_a_user) - self.assertTrue(user_1a != not_a_user) - self.assertFalse(user_1a == not_a_user) def test_get_name(self): # Arrange @@ -431,7 +423,7 @@ class TestPyLastUser(PyLastTestCase): track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert - self.assertFalse(track1 == track2) + self.assertNotEqual(track1, track2) def test_track_ne_none_is_true(self): # Arrange @@ -439,7 +431,7 @@ class TestPyLastUser(PyLastTestCase): track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert - self.assertTrue(track1 != track2) + self.assertNotEqual(track1, track2) def test_track_get_correction(self): # Arrange @@ -459,7 +451,7 @@ class TestPyLastUser(PyLastTestCase): mbid = track.get_mbid() # Assert - self.assertEqual(mbid, None) + self.assertIsNone(mbid) def test_get_playcount(self): # Arrange From 4f4d204300c31d62cb3383134220d8e4d4bf8de9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 27 Jan 2018 13:01:22 +0200 Subject: [PATCH 387/768] Define __ne__ in terms of == instead of __eq__ --- pylast/__init__.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 2fd0f12..32bf15c 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1398,7 +1398,7 @@ class _Opus(_BaseObject, _Taggable): return (a == b) and (c == d) def __ne__(self, other): - return not self.__eq__(other) + return not self == other def _get_params(self): return { @@ -1562,7 +1562,7 @@ class Artist(_BaseObject, _Taggable): return False def __ne__(self, other): - return not self.__eq__(other) + return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} @@ -1750,7 +1750,7 @@ class Country(_BaseObject): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): - return self.get_name() != other.get_name() + return not self == other def _get_params(self): # TODO can move to _BaseObject return {'country': self.get_name()} @@ -1878,7 +1878,7 @@ class Tag(_BaseObject, _Chartable): return self.get_name().lower() == other.get_name().lower() def __ne__(self, other): - return self.get_name().lower() != other.get_name().lower() + return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} @@ -2081,17 +2081,14 @@ class User(_BaseObject, _Chartable): def __str__(self): return self.get_name() - def __eq__(self, another): - if isinstance(another, User): - return self.get_name() == another.get_name() + def __eq__(self, other): + if isinstance(other, User): + return self.get_name() == other.get_name() else: return False - def __ne__(self, another): - if isinstance(another, User): - return self.get_name() != another.get_name() - else: - return True + def __ne__(self, other): + return not self == other def _get_params(self): return {self.ws_prefix: self.get_name()} From 99ad248e6d84e0f9056cc0ad3b9304dcbd6bb6dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 27 Jan 2018 13:34:01 +0200 Subject: [PATCH 388/768] Ignore Clone Digger output --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 51a71f6..12dc56a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ target/ # JetBrains .idea/ + +# Clone Digger +output.html From b02b83028298f7df04931ec93e0d4683b9241701 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 27 Jan 2018 13:34:54 +0200 Subject: [PATCH 389/768] Remove Landscape badge, it's failing to check --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 50b9a89..e3497c0 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) -[![Code health](https://landscape.io/github/pylast/pylast/master/landscape.svg)](https://landscape.io/github/hugovk/pylast/master) - A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). From 41f8ae95d1e95ce561385c18f84194a164cddf60 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 6 Feb 2018 17:46:08 +0200 Subject: [PATCH 390/768] Use named version attributes (new in Py2.7) --- pylast/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 32bf15c..38dc47c 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -39,14 +39,14 @@ __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' -if sys.version_info[0] == 3: +if sys.version_info.major == 3: import html.entities as htmlentitydefs from http.client import HTTPSConnection from urllib.parse import quote_plus as url_quote_plus unichr = chr -elif sys.version_info[0] == 2: +elif sys.version_info.major == 2: import htmlentitydefs from httplib import HTTPSConnection from urllib import quote_plus as url_quote_plus @@ -2608,7 +2608,7 @@ def _string(string): if isinstance(string, str): return string casted = six.text_type(string) - if sys.version_info[0] == 2: + if sys.version_info.major == 2: casted = casted.encode("utf-8") return casted From 8e478e79ea3e45e40299f9ac6be5cc26d4a23aaa Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 6 Feb 2018 17:47:39 +0200 Subject: [PATCH 391/768] Treat Py2 (not Py3) as the special case https://astrofrog.github.io/blog/2016/01/12/stop-writing-python-4-incompatible-code/ --- pylast/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 38dc47c..a1ee98f 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -39,17 +39,16 @@ __license__ = "apache2" __email__ = 'amr.hassan@gmail.com' -if sys.version_info.major == 3: - import html.entities as htmlentitydefs - from http.client import HTTPSConnection - from urllib.parse import quote_plus as url_quote_plus - - unichr = chr - -elif sys.version_info.major == 2: +if sys.version_info.major == 2: import htmlentitydefs from httplib import HTTPSConnection from urllib import quote_plus as url_quote_plus +else: + import html.entities as htmlentitydefs + from http.client import HTTPSConnection + from urllib.parse import quote_plus as url_quote_plus + unichr = chr + STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 From fa425188db8461e6c1866cefe532b4378f88e535 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 8 Feb 2018 14:14:25 +0200 Subject: [PATCH 392/768] Add issue and PR templates --- .github/ISSUE_TEMPLATE.md | 15 +++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..97aaea8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +### What did you do? + +### What did you expect to happen? + +### What actually happened? + +### What versions of OS, Python and pylast are you using? + +Please include **code** that reproduces the issue. + +The [best reproductions](https://stackoverflow.com/help/mcve) are [self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) with minimal dependencies. + +```python +code goes here +``` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..225fdc9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +Fixes # + +Changes proposed in this pull request: + + * + * + * From bb589a4d2193400eb84adf14578f696228abbe08 Mon Sep 17 00:00:00 2001 From: Chris Newton Date: Wed, 28 Feb 2018 17:04:46 -0700 Subject: [PATCH 393/768] Add missing limit parameter for track.getSimilar --- pylast/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index a1ee98f..f644f3c 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2016,13 +2016,17 @@ class Track(_Opus): self._request(self.ws_prefix + '.unlove') - def get_similar(self): + def get_similar(self, limit=None): """ Returns similar tracks for this track on the network, based on listening data. """ - doc = self._request(self.ws_prefix + '.getSimilar', True) + params = self._get_params() + if limit: + params['limit'] = limit + + doc = self._request(self.ws_prefix + '.getSimilar', True, params) seq = [] for node in doc.getElementsByTagName(self.ws_prefix): From 9cb6f6e181becb93e110b98d0faa1a5de8f72046 Mon Sep 17 00:00:00 2001 From: Chris Newton Date: Wed, 28 Feb 2018 20:28:56 -0700 Subject: [PATCH 394/768] Added a test case for limits on track.getSimilar --- tests/test_pylast_track.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_pylast_track.py b/tests/test_pylast_track.py index 7b0c99a..6add41e 100755 --- a/tests/test_pylast_track.py +++ b/tests/test_pylast_track.py @@ -160,6 +160,16 @@ class TestPyLastTrack(PyLastTestCase): break self.assertTrue(found) + def test_track_get_similar_limits(self): + # Arrange + track = pylast.Track("Cher", "Believe", self.network) + + # Act/Assert + self.assertEqual(len(track.get_similar(limit=20)), 20) + self.assertLessEqual(len(track.get_similar(limit=10)), 10) + self.assertGreaterEqual(len(track.get_similar(limit=None)), 23) + self.assertGreaterEqual(len(track.get_similar(limit=0)), 23) + if __name__ == '__main__': unittest.main(failfast=True) From 36f7e9619b3e62d4d82e000a61e98e1e92c8751f Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 1 Mar 2018 11:19:34 +0200 Subject: [PATCH 395/768] Shorten test names --- tests/{test_pylast_album.py => test_album.py} | 0 tests/{test_pylast_artist.py => test_artist.py} | 0 tests/{test_pylast_country.py => test_country.py} | 0 tests/{test_pylast_library.py => test_library.py} | 0 tests/{test_pylast_librefm.py => test_librefm.py} | 0 tests/{test_pylast_network.py => test_network.py} | 0 tests/{test_pylast_tag.py => test_tag.py} | 0 tests/{test_pylast_track.py => test_track.py} | 0 tests/{test_pylast_user.py => test_user.py} | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename tests/{test_pylast_album.py => test_album.py} (100%) rename tests/{test_pylast_artist.py => test_artist.py} (100%) rename tests/{test_pylast_country.py => test_country.py} (100%) rename tests/{test_pylast_library.py => test_library.py} (100%) rename tests/{test_pylast_librefm.py => test_librefm.py} (100%) rename tests/{test_pylast_network.py => test_network.py} (100%) rename tests/{test_pylast_tag.py => test_tag.py} (100%) rename tests/{test_pylast_track.py => test_track.py} (100%) rename tests/{test_pylast_user.py => test_user.py} (100%) diff --git a/tests/test_pylast_album.py b/tests/test_album.py similarity index 100% rename from tests/test_pylast_album.py rename to tests/test_album.py diff --git a/tests/test_pylast_artist.py b/tests/test_artist.py similarity index 100% rename from tests/test_pylast_artist.py rename to tests/test_artist.py diff --git a/tests/test_pylast_country.py b/tests/test_country.py similarity index 100% rename from tests/test_pylast_country.py rename to tests/test_country.py diff --git a/tests/test_pylast_library.py b/tests/test_library.py similarity index 100% rename from tests/test_pylast_library.py rename to tests/test_library.py diff --git a/tests/test_pylast_librefm.py b/tests/test_librefm.py similarity index 100% rename from tests/test_pylast_librefm.py rename to tests/test_librefm.py diff --git a/tests/test_pylast_network.py b/tests/test_network.py similarity index 100% rename from tests/test_pylast_network.py rename to tests/test_network.py diff --git a/tests/test_pylast_tag.py b/tests/test_tag.py similarity index 100% rename from tests/test_pylast_tag.py rename to tests/test_tag.py diff --git a/tests/test_pylast_track.py b/tests/test_track.py similarity index 100% rename from tests/test_pylast_track.py rename to tests/test_track.py diff --git a/tests/test_pylast_user.py b/tests/test_user.py similarity index 100% rename from tests/test_pylast_user.py rename to tests/test_user.py From b87fefe9ab1478a86074939b4617d91efcd32957 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 1 Mar 2018 11:23:06 +0200 Subject: [PATCH 396/768] Move track tests to test_track.py --- tests/test_track.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_user.py | 79 --------------------------------------------- 2 files changed, 79 insertions(+), 79 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index 6add41e..25bba36 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -170,6 +170,85 @@ class TestPyLastTrack(PyLastTestCase): self.assertGreaterEqual(len(track.get_similar(limit=None)), 23) self.assertGreaterEqual(len(track.get_similar(limit=0)), 23) + def test_tracks_notequal(self): + # Arrange + track1 = pylast.Track("Test Artist", "test title", self.network) + track2 = pylast.Track("Test Artist", "Test Track", self.network) + + # Act + # Assert + self.assertNotEqual(track1, track2) + + def test_track_title_prop_caps(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + title = track.get_title(properly_capitalized=True) + + # Assert + self.assertEqual(title, "Test Title") + + def test_track_listener_count(self): + # Arrange + track = pylast.Track("test artist", "test title", self.network) + + # Act + count = track.get_listener_count() + + # Assert + self.assertGreater(count, 21) + + def test_album_tracks(self): + # Arrange + album = pylast.Album("Test Artist", "Test Release", self.network) + + # Act + tracks = album.get_tracks() + url = tracks[0].get_url() + + # Assert + self.assertIsInstance(tracks, list) + self.assertIsInstance(tracks[0], pylast.Track) + self.assertEqual(len(tracks), 4) + self.assertTrue(url.startswith("https://www.last.fm/music/test")) + + def test_track_eq_none_is_false(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "test title", self.network) + + # Act / Assert + self.assertNotEqual(track1, track2) + + def test_track_ne_none_is_true(self): + # Arrange + track1 = None + track2 = pylast.Track("Test Artist", "test title", self.network) + + # Act / Assert + self.assertNotEqual(track1, track2) + + def test_track_get_correction(self): + # Arrange + track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) + + # Act + corrected_track_name = track.get_correction() + + # Assert + self.assertEqual(corrected_track_name, "Mr. Brownstone") + + def test_track_with_no_mbid(self): + # Arrange + track = pylast.Track("Static-X", "Set It Off", self.network) + + # Act + mbid = track.get_mbid() + + # Assert + self.assertIsNone(mbid) + if __name__ == '__main__': unittest.main(failfast=True) diff --git a/tests/test_user.py b/tests/test_user.py index ad12feb..70a69e8 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -374,85 +374,6 @@ class TestPyLastUser(PyLastTestCase): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") - def test_tracks_notequal(self): - # Arrange - track1 = pylast.Track("Test Artist", "test title", self.network) - track2 = pylast.Track("Test Artist", "Test Track", self.network) - - # Act - # Assert - self.assertNotEqual(track1, track2) - - def test_track_title_prop_caps(self): - # Arrange - track = pylast.Track("test artist", "test title", self.network) - - # Act - title = track.get_title(properly_capitalized=True) - - # Assert - self.assertEqual(title, "Test Title") - - def test_track_listener_count(self): - # Arrange - track = pylast.Track("test artist", "test title", self.network) - - # Act - count = track.get_listener_count() - - # Assert - self.assertGreater(count, 21) - - def test_album_tracks(self): - # Arrange - album = pylast.Album("Test Artist", "Test Release", self.network) - - # Act - tracks = album.get_tracks() - url = tracks[0].get_url() - - # Assert - self.assertIsInstance(tracks, list) - self.assertIsInstance(tracks[0], pylast.Track) - self.assertEqual(len(tracks), 4) - self.assertTrue(url.startswith("https://www.last.fm/music/test")) - - def test_track_eq_none_is_false(self): - # Arrange - track1 = None - track2 = pylast.Track("Test Artist", "test title", self.network) - - # Act / Assert - self.assertNotEqual(track1, track2) - - def test_track_ne_none_is_true(self): - # Arrange - track1 = None - track2 = pylast.Track("Test Artist", "test title", self.network) - - # Act / Assert - self.assertNotEqual(track1, track2) - - def test_track_get_correction(self): - # Arrange - track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) - - # Act - corrected_track_name = track.get_correction() - - # Assert - self.assertEqual(corrected_track_name, "Mr. Brownstone") - - def test_track_with_no_mbid(self): - # Arrange - track = pylast.Track("Static-X", "Set It Off", self.network) - - # Act - mbid = track.get_mbid() - - # Assert - self.assertIsNone(mbid) - def test_get_playcount(self): # Arrange user = self.network.get_user("RJ") From acdc23d7e76510fc28126c6ee20422af9412a3b2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 1 Mar 2018 11:24:53 +0200 Subject: [PATCH 397/768] Fix test case for changed test data --- tests/test_track.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index 25bba36..efa0812 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -201,7 +201,7 @@ class TestPyLastTrack(PyLastTestCase): def test_album_tracks(self): # Arrange - album = pylast.Album("Test Artist", "Test Release", self.network) + album = pylast.Album("Test Artist", "Test", self.network) # Act tracks = album.get_tracks() @@ -210,7 +210,7 @@ class TestPyLastTrack(PyLastTestCase): # Assert self.assertIsInstance(tracks, list) self.assertIsInstance(tracks[0], pylast.Track) - self.assertEqual(len(tracks), 4) + self.assertEqual(len(tracks), 1) self.assertTrue(url.startswith("https://www.last.fm/music/test")) def test_track_eq_none_is_false(self): From 0a152fa35d4ffb6107c0c42477ada8d74f8cd97d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 1 Mar 2018 11:47:03 +0200 Subject: [PATCH 398/768] Codecov: Avoid "Missing base report" [CI skip] --- .codecov.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..7c94328 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,7 @@ +# Documentation: https://docs.codecov.io/docs/codecov-yaml + +codecov: + # Avoid "Missing base report" + # https://github.com/codecov/support/issues/363 + # https://docs.codecov.io/v4.3.6/docs/comparing-commits + allow_coverage_offsets: true From 77e06add286e2d4544031fd1d9baf4cffcb16359 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 15 Mar 2018 10:27:51 +0200 Subject: [PATCH 399/768] Add 'and Contributors' to Author --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2e73407..7a3d8ae 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import find_packages, setup setup( name="pylast", version="2.2.0.dev0", - author="Amr Hassan ", + author="Amr Hassan and Contributors", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', 'pyflakes', 'flaky'], From 8670a87255ae8ed7a439e3cff95dd5f3eed6be90 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 15 Mar 2018 21:52:41 +0200 Subject: [PATCH 400/768] Do not use a mutable data structure for argument default --- pylast/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index f644f3c..13a8681 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -731,7 +731,10 @@ class _ShelfCacheBackend(object): class _Request(object): """Representing an abstract web service operation.""" - def __init__(self, network, method_name, params={}): + def __init__(self, network, method_name, params=None): + + if params is None: + params = {} self.network = network self.params = {} From 0759bf5b0cd6eea2830016f0b7c5183107a778a6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 18 Mar 2018 09:58:41 +0200 Subject: [PATCH 401/768] Test against a certain user One we know has more than two top tracks, rather than a new test user who might not. --- tests/test_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_user.py b/tests/test_user.py index 70a69e8..6e42530 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -214,7 +214,7 @@ class TestPyLastUser(PyLastTestCase): def test_user_top_tracks(self): # Arrange - lastfm_user = self.network.get_user(self.username) + lastfm_user = self.network.get_user("RJ") # Act things = lastfm_user.get_top_tracks(limit=2) From 20250237b9bf616cdfb5956332e34d25d4889b8a Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 26 Mar 2018 19:41:54 +0300 Subject: [PATCH 402/768] Add Python 3.7 --- .travis.yml | 2 ++ setup.py | 1 + tox.ini | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 39948ff..d1f1e8c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,8 @@ matrix: env: TOXENV=pypy - python: 3.6-dev env: TOXENV=py36dev + - python: 3.7-dev + env: TOXENV=py37dev allow_failures: - env: TOXENV=pypy - env: TOXENV=pypy3 diff --git a/setup.py b/setup.py index 7a3d8ae..f0d4e45 100755 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ setup( "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], diff --git a/tox.ini b/tox.ini index 1cfc777..f26cc12 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py36, py35, py34, pypy, pypy3, py36dev +envlist = py27, py36, py35, py34, pypy, pypy3, py36dev, py37dev recreate = False [testenv] From f2ec5bc57ef06c0f8db9a92f2ebb628460e358aa Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 27 Mar 2018 13:36:16 +0300 Subject: [PATCH 403/768] Add workaround for build failures on Python 3.7 (https://github.com/yaml/pyyaml/issues/126) --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index f26cc12..590edf9 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,9 @@ setenv = PYLAST_API_KEY={env:PYLAST_API_KEY:} PYLAST_API_SECRET={env:PYLAST_API_SECRET:} deps = - pyyaml + # Workaround for yaml/pyyaml#126 + py27,py36,py35,py34,pypy,pypy3,py36dev: pyyaml + py37dev: git+https://github.com/yaml/pyyaml@master#egg=pyyaml pytest mock ipdb From 8c5799a4d697ddd27c9b55b8abc172046a2d0d7d Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 27 Mar 2018 13:57:42 +0300 Subject: [PATCH 404/768] Refactor lint to remove duplicate --- tox.ini | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tox.ini b/tox.ini index 590edf9..e5ca35d 100644 --- a/tox.ini +++ b/tox.ini @@ -23,24 +23,26 @@ commands = pytest -v -s -W all --cov pylast --cov-report term-missing {posargs} deps = ipdb commands = {posargs} -[testenv:py2lint] +[testenv:lint] deps = pycodestyle pyflakes - clonedigger commands = pyflakes pylast pyflakes tests pycodestyle pylast pycodestyle tests + +[testenv:py2lint] +deps = + {[testenv:lint]deps} + clonedigger +commands = + {[testenv:lint]commands} ./clonedigger.sh [testenv:py3lint] deps = - pycodestyle - pyflakes + {[testenv:lint]deps} commands = - pyflakes pylast - pyflakes tests - pycodestyle pylast - pycodestyle tests + {[testenv:lint]commands} From 06f6cf627d8dc6b9be62056c52d10d142eb72f29 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 5 Apr 2018 18:02:16 +0300 Subject: [PATCH 405/768] Failing test for user.get_weekly_artist_charts --- tests/test_user.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 6e42530..c742bd3 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -388,9 +388,10 @@ class TestPyLastUser(PyLastTestCase): # Arrange user = self.network.get_user("RJ") - # Act / Assert + # Act image = user.get_image() + # Assert self.assertTrue(image.startswith("https://")) self.assertTrue(image.endswith(".png")) @@ -398,11 +399,36 @@ class TestPyLastUser(PyLastTestCase): # Arrange user = self.network.get_user("RJ") - # Act / Assert + # Act url = user.get_url() + # Assert self.assertEqual(url, "https://www.last.fm/user/rj") + def test_get_weekly_artist_charts(self): + # Arrange + user = self.network.get_user("bbc6music") + + # Act + charts = user.get_weekly_artist_charts() + artist, weight = charts[0] + + # Assert + self.assertIsNotNone(artist) + self.assertIsInstance(artist.network, pylast.LastFMNetwork) + + def test_get_weekly_track_charts(self): + # Arrange + user = self.network.get_user("bbc6music") + + # Act + charts = user.get_weekly_track_charts() + track, weight = charts[0] + + # Assert + self.assertIsNotNone(track) + self.assertIsInstance(track.network, pylast.LastFMNetwork) + if __name__ == '__main__': unittest.main(failfast=True) From 892acd6df9b30f8fbd7167e838249d0ff41044da Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 5 Apr 2018 18:08:21 +0300 Subject: [PATCH 406/768] Artist charts have the artist name in the 'name' field. There is no 'artist' field. --- pylast/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 13a8681..848c0df 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1169,8 +1169,15 @@ class _Chartable(object): seq = [] for node in doc.getElementsByTagName(chart_kind.lower()): - item = chart_type( - _extract(node, "artist"), _extract(node, "name"), self.network) + if chart_kind == "artist": + item = chart_type( + _extract(node, "name"), + self.network) + else: + item = chart_type( + _extract(node, "artist"), + _extract(node, "name"), + self.network) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) From dfb1ce2e2ed985e569c13bb13dc5291f0a2a60d8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 5 Apr 2018 18:10:59 +0300 Subject: [PATCH 407/768] pylast 2.2.0 will support Python 3.7 [CI skip] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3497c0..8b36b89 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Or from requirements.txt: Note: -* pyLast >= 2.0.0 supports Python 2.7.10+ and 3.4, 3.5, 3.6. +* pyLast >= 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. +* pyLast >= 2.0.0 < 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. * pyLast >= 1.7.0 < 2.0.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. * pyLast >= 1.0.0 < 1.7.0 supports Python 2.7, 3.3, 3.4. * pyLast >= 0.5 < 1.0.0 supports Python 2, 3. From c5684bb87a9bdae3e96770596972f19ccaeb4a91 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 6 Apr 2018 13:11:28 +0300 Subject: [PATCH 408/768] Use Markdown on PyPI --- setup.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.py b/setup.py index f0d4e45..e74d241 100755 --- a/setup.py +++ b/setup.py @@ -1,9 +1,13 @@ #!/usr/bin/env python from setuptools import find_packages, setup +with open("README.md") as f: + long_description = f.read() setup( name="pylast", + long_description=long_description, + long_description_content_type="text/markdown", version="2.2.0.dev0", author="Amr Hassan and Contributors", install_requires=['six'], From a607dbe491cd8a948a4280eff90876dda1e5b46d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 8 Apr 2018 16:45:36 +0300 Subject: [PATCH 409/768] flake8 --- tests/test_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index f3d8cb8..fafe66f 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -26,8 +26,8 @@ class TestPyLastNetwork(PyLastTestCase): # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertEqual(str(last_scrobble.track.artist).lower(), artist) - self.assertEqual(str(last_scrobble.track.title).lower(), title) - self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) + self.assertEqual(str(last_scrobble.track.title).lower(), title) + self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) def test_update_now_playing(self): # Arrange From 2e52023a222b84c72f5ec91c38d0a5a72eec4c4f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 8 Apr 2018 17:12:36 +0300 Subject: [PATCH 410/768] Release 2.2.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 848c0df..9dbec93 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '2.2.0.dev0' +__version__ = '2.2.0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' '2017 Mice Pápai') diff --git a/setup.py b/setup.py index e74d241..6948344 100755 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", - version="2.2.0.dev0", + version="2.2.0", author="Amr Hassan and Contributors", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From 4cf7e8c727d73babc57bf2eec28c50d76dd61700 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 8 Apr 2018 17:18:10 +0300 Subject: [PATCH 411/768] Start new release cycle --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 9dbec93..195a649 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -31,7 +31,7 @@ import tempfile import time import xml.dom -__version__ = '2.2.0' +__version__ = '2.3.0.dev0' __author__ = 'Amr Hassan, hugovk, Mice Pápai' __copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' '2017 Mice Pápai') diff --git a/setup.py b/setup.py index 6948344..7ecd757 100755 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", - version="2.2.0", + version="2.3.0.dev0", author="Amr Hassan and Contributors", install_requires=['six'], tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', From 1181dc4f35e0c4c7ac1b46415ae1be732704ee05 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 8 Apr 2018 17:23:18 +0300 Subject: [PATCH 412/768] Link badges to all new PyPI [CI skip] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8b36b89..f50cc15 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ pyLast ====== -[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.python.org/pypi/pylast/) -[![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.python.org/pypi/pylast/) +[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) From c929b5bd8f1f0f4dca3994abefca2e7683bc85c4 Mon Sep 17 00:00:00 2001 From: hugovk Date: Wed, 11 Apr 2018 00:19:24 +0300 Subject: [PATCH 413/768] Show deprecation warnings for tests --- pytest.ini | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..34667c8 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +filterwarnings = + once::DeprecationWarning + once::PendingDeprecationWarning From 85efc31c1044ffab820e51133196d9c99310b37c Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 11 Apr 2018 10:38:00 +0300 Subject: [PATCH 414/768] Close HTTPSConnection in case of exception --- pylast/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 195a649..16dc353 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -857,8 +857,10 @@ class _Request(object): except Exception as e: raise MalformedResponseError(self.network, e) - self._check_response_for_errors(response_text) - conn.close() + try: + self._check_response_for_errors(response_text) + finally: + conn.close() return response_text def execute(self, cacheable=False): From 887a47c51599e176026212db365815e02914c2dd Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 11 Apr 2018 11:25:42 +0300 Subject: [PATCH 415/768] Ignore new '.pytest_cache' directory --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 12dc56a..8e4ffaf 100644 --- a/.gitignore +++ b/.gitignore @@ -47,8 +47,9 @@ htmlcov/ .cache nosetests.xml coverage.xml -*,cover +*.cover .hypothesis/ +.pytest_cache/ # Translations *.mo From c2dfe04e1786226eb68ccaaa3e85fc0e64ab2ce5 Mon Sep 17 00:00:00 2001 From: zinootje Date: Sat, 14 Apr 2018 19:43:47 +0200 Subject: [PATCH 416/768] add images url on get next page --- pylast/__init__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 16dc353..2bf9b1b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1369,7 +1369,7 @@ class _Opus(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, artist, title, network, ws_prefix, username=None): + def __init__(self, artist, title, network, ws_prefix, username=None, images=None): """ Create an opus instance. # Parameters: @@ -1388,6 +1388,7 @@ class _Opus(_BaseObject, _Taggable): self.title = title self.username = username + self.images = images def __repr__(self): return "pylast.%s(%s, %s, %s)" % ( @@ -1485,8 +1486,8 @@ class Album(_Opus): __hash__ = _Opus.__hash__ - def __init__(self, artist, title, network, username=None): - super(Album, self).__init__(artist, title, network, "album", username) + def __init__(self, artist, title, network, username=None, images=None): + super(Album, self).__init__(artist, title, network, "album", username, images) def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ @@ -1497,10 +1498,12 @@ class Album(_Opus): SIZE_MEDIUM SIZE_SMALL """ - - return _extract_all( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] + if not self.images: + return _extract_all( + self._request( + self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] + else: + return self.images[size] def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -2544,7 +2547,8 @@ class AlbumSearch(_Search): seq.append(Album( _extract(node, "artist"), _extract(node, "name"), - self.network)) + self.network, + images=_extract_all(node, 'image'))) return seq From 971e4e35719b497c4e4a204668577b5fbd37b67c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 14 Apr 2018 22:06:57 +0300 Subject: [PATCH 417/768] flake8 --- pylast/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 2bf9b1b..0586b42 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1369,7 +1369,8 @@ class _Opus(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, artist, title, network, ws_prefix, username=None, images=None): + def __init__(self, artist, title, network, ws_prefix, username=None, + images=None): """ Create an opus instance. # Parameters: @@ -1487,7 +1488,8 @@ class Album(_Opus): __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, images=None): - super(Album, self).__init__(artist, title, network, "album", username, images) + super(Album, self).__init__(artist, title, network, "album", username, + images) def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ @@ -1500,8 +1502,8 @@ class Album(_Opus): """ if not self.images: return _extract_all( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), 'image')[size] + self._request(self.ws_prefix + ".getInfo", cacheable=True), + 'image')[size] else: return self.images[size] From cf2d9113dd20a3b15f082d8851daf85cd613082b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 14 Apr 2018 22:38:43 +0300 Subject: [PATCH 418/768] Add test for storing album images on search --- tests/test_network.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_network.py b/tests/test_network.py index fafe66f..ab5dad2 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -315,6 +315,26 @@ class TestPyLastNetwork(PyLastTestCase): self.assertIsInstance(results, list) self.assertIsInstance(results[0], pylast.Album) + def test_album_search_images(self): + # Arrange + album = "Nevermind" + search = self.network.search_for_album(album) + + # Act + results = search.get_next_page() + images = results[0].images + + # Assert + self.assertEqual(len(images), 4) + + self.assertTrue(images[pylast.SIZE_SMALL].startswith("https://")) + self.assertTrue(images[pylast.SIZE_SMALL].endswith(".png")) + self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + + self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].startswith("https://")) + self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].endswith(".png")) + self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + def test_artist_search(self): # Arrange artist = "Nirvana" From 4083f72ef7825c4919ee424fc600f6808979311d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 14 Apr 2018 22:50:42 +0300 Subject: [PATCH 419/768] Store all images on get_cover_image for future use --- pylast/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0586b42..3b9b0c1 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -732,6 +732,7 @@ class _Request(object): """Representing an abstract web service operation.""" def __init__(self, network, method_name, params=None): + print(method_name) if params is None: params = {} @@ -1501,11 +1502,10 @@ class Album(_Opus): SIZE_SMALL """ if not self.images: - return _extract_all( + self.images = _extract_all( self._request(self.ws_prefix + ".getInfo", cacheable=True), - 'image')[size] - else: - return self.images[size] + 'image') + return self.images[size] def get_tracks(self): """Returns the list of Tracks on this album.""" From f923cd2c50fe4e66622776d993535ab8332d596c Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 15 Apr 2018 21:55:08 +0300 Subject: [PATCH 420/768] Replace print with logging --- README.md | 20 ++++++++++++++++++++ pylast/__init__.py | 6 +++++- tests/test_track.py | 1 - 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f50cc15..31b717c 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,23 @@ coverage report # for command-line report coverage html # for HTML report open htmlcov/index.html ``` + +Logging +------- + +To enable from your own code: + +```python +import logging +import pylast + +logging.basicConfig(level=logging.DEBUG) + +network = pylast.LastFMNetwork(...) +``` + +To enable from pytest: + +```sh +pytest -k test_album_search_images --log-cli-level debug +``` diff --git a/pylast/__init__.py b/pylast/__init__.py index 3b9b0c1..839f6a5 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -23,6 +23,7 @@ from xml.dom import minidom, Node import collections import hashlib +import logging import shelve import six import ssl @@ -110,6 +111,9 @@ SCROBBLE_MODE_SKIPPED = "S" # Python >3.4 and >2.7.9 has sane defaults SSL_CONTEXT = ssl.create_default_context() +logger = logging.getLogger(__name__) +logging.getLogger(__name__).addHandler(logging.NullHandler()) + class _Network(object): """ @@ -732,7 +736,7 @@ class _Request(object): """Representing an abstract web service operation.""" def __init__(self, network, method_name, params=None): - print(method_name) + logger.debug(method_name) if params is None: params = {} diff --git a/tests/test_track.py b/tests/test_track.py index efa0812..bb34f56 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -140,7 +140,6 @@ class TestPyLastTrack(PyLastTestCase): # Act album = track.get_album() - print(album) # Assert self.assertEqual(str(album), "Nirvana - Nevermind") From 942ced319af2dfcc4ee2f7b01f2a1679bfc19f35 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 15 Apr 2018 23:32:23 +0300 Subject: [PATCH 421/768] Capitalise URI/URL in comments --- pylast/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 839f6a5..d157326 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -903,7 +903,7 @@ class SessionKeyGenerator(object): a. network = get_*_network(API_KEY, API_SECRET) b. sg = SessionKeyGenerator(network) c. url = sg.get_web_auth_url() - d. Ask the user to open the url and authorize you, and wait for it. + d. Ask the user to open the URL and authorize you, and wait for it. e. session_key = sg.get_web_auth_session_key(url) 2) Username and Password Authentication: a. network = get_*_network(API_KEY, API_SECRET) @@ -961,7 +961,7 @@ class SessionKeyGenerator(object): def get_web_auth_session_key(self, url, token=""): """ - Retrieves the session key of a web authorization process by its url. + Retrieves the session key of a web authorization process by its URL. """ if url in self.web_auth_tokens.keys(): @@ -1498,7 +1498,7 @@ class Album(_Opus): def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ - Returns a uri to the cover image + Returns a URI to the cover image size can be one of: SIZE_EXTRA_LARGE SIZE_LARGE @@ -1606,7 +1606,7 @@ class Artist(_BaseObject, _Taggable): def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ - Returns a uri to the cover image + Returns a URI to the cover image size can be one of: SIZE_MEGA SIZE_EXTRA_LARGE @@ -1724,7 +1724,7 @@ class Artist(_BaseObject, _Taggable): "getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the artist page on the network. + """Returns the URL of the artist page on the network. # Parameters: * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH @@ -1800,7 +1800,7 @@ class Country(_BaseObject): "getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the country page on the network. + """Returns the URL of the country page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -1945,7 +1945,7 @@ class Tag(_BaseObject, _Chartable): return _extract_top_artists(doc, self.network) def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the tag page on the network. + """Returns the URL of the tag page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2454,7 +2454,7 @@ class User(_BaseObject, _Chartable): return _extract_all(doc, "image")[size] def get_url(self, domain_name=DOMAIN_ENGLISH): - """Returns the url of the user page on the network. + """Returns the URL of the user page on the network. * domain_name: The network's language domain. Possible values: o DOMAIN_ENGLISH o DOMAIN_GERMAN @@ -2767,7 +2767,7 @@ def _extract_tracks(doc, network): def _url_safe(text): - """Does all kinds of tricks on a text to make it safe to use in a url.""" + """Does all kinds of tricks on a text to make it safe to use in a URL.""" return url_quote_plus(url_quote_plus(_string(text))).lower() From d1af8d3ebca1f26be90fc3c6c8460e7d8a330a43 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 15 Apr 2018 23:43:39 +0300 Subject: [PATCH 422/768] Store artist images for future use --- README.md | 2 +- pylast/__init__.py | 16 +++++++++++----- tests/test_network.py | 20 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 31b717c..f4af28b 100644 --- a/README.md +++ b/README.md @@ -133,5 +133,5 @@ network = pylast.LastFMNetwork(...) To enable from pytest: ```sh -pytest -k test_album_search_images --log-cli-level debug +pytest --log-cli-level debug -k test_album_search_images ``` diff --git a/pylast/__init__.py b/pylast/__init__.py index d157326..7379850 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1552,7 +1552,7 @@ class Artist(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, name, network, username=None): + def __init__(self, name, network, username=None, images=None): """Create an artist object. # Parameters: * name str: The artist's name. @@ -1563,6 +1563,7 @@ class Artist(_BaseObject, _Taggable): self.name = name self.username = username + self.images = images def __repr__(self): return "pylast.Artist(%s, %s)" % ( @@ -1615,8 +1616,11 @@ class Artist(_BaseObject, _Taggable): SIZE_SMALL """ - return _extract_all( - self._request(self.ws_prefix + ".getInfo", True), "image")[size] + if not self.images: + self.images = _extract_all( + self._request(self.ws_prefix + ".getInfo", cacheable=True), + "image") + return self.images[size] def get_playcount(self): """Returns the number of plays on the network.""" @@ -2554,7 +2558,8 @@ class AlbumSearch(_Search): _extract(node, "artist"), _extract(node, "name"), self.network, - images=_extract_all(node, 'image'))) + images=_extract_all(node, 'image')), + ) return seq @@ -2572,7 +2577,8 @@ class ArtistSearch(_Search): seq = [] for node in master_node.getElementsByTagName("artist"): - artist = Artist(_extract(node, "name"), self.network) + artist = Artist(_extract(node, "name"), self.network, + images=_extract_all(node, "image")) artist.listener_count = _number(_extract(node, "listeners")) seq.append(artist) diff --git a/tests/test_network.py b/tests/test_network.py index ab5dad2..f7eec7e 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -347,6 +347,26 @@ class TestPyLastNetwork(PyLastTestCase): self.assertIsInstance(results, list) self.assertIsInstance(results[0], pylast.Artist) + def test_artist_search_images(self): + # Arrange + artist = "Nirvana" + search = self.network.search_for_artist(artist) + + # Act + results = search.get_next_page() + images = results[0].images + + # Assert + self.assertEqual(len(images), 5) + + self.assertTrue(images[pylast.SIZE_SMALL].startswith("https://")) + self.assertTrue(images[pylast.SIZE_SMALL].endswith(".png")) + self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + + self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].startswith("https://")) + self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].endswith(".png")) + self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + def test_track_search(self): # Arrange artist = "Nirvana" From fa601b8111245a06b18e7a8eab64fc55697d42d0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 16 Apr 2018 09:53:31 +0300 Subject: [PATCH 423/768] Refactor to store Album/Artist images in info dict --- pylast/__init__.py | 36 +++++++++++++++++++++--------------- tests/test_network.py | 4 ++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 7379850..090cadc 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1375,7 +1375,7 @@ class _Opus(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ def __init__(self, artist, title, network, ws_prefix, username=None, - images=None): + info=None): """ Create an opus instance. # Parameters: @@ -1384,6 +1384,9 @@ class _Opus(_BaseObject, _Taggable): * ws_prefix: 'album' or 'track' """ + if info is None: + info = {} + _BaseObject.__init__(self, network, ws_prefix) _Taggable.__init__(self, ws_prefix) @@ -1394,7 +1397,7 @@ class _Opus(_BaseObject, _Taggable): self.title = title self.username = username - self.images = images + self.info = info def __repr__(self): return "pylast.%s(%s, %s, %s)" % ( @@ -1492,9 +1495,9 @@ class Album(_Opus): __hash__ = _Opus.__hash__ - def __init__(self, artist, title, network, username=None, images=None): + def __init__(self, artist, title, network, username=None, info=None): super(Album, self).__init__(artist, title, network, "album", username, - images) + info) def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ @@ -1505,11 +1508,11 @@ class Album(_Opus): SIZE_MEDIUM SIZE_SMALL """ - if not self.images: - self.images = _extract_all( + if "image" not in self.info: + self.info["image"] = _extract_all( self._request(self.ws_prefix + ".getInfo", cacheable=True), - 'image') - return self.images[size] + "image") + return self.info["image"][size] def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -1552,18 +1555,21 @@ class Artist(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, name, network, username=None, images=None): + def __init__(self, name, network, username=None, info=None): """Create an artist object. # Parameters: * name str: The artist's name. """ + if info is None: + info = {} + _BaseObject.__init__(self, network, 'artist') _Taggable.__init__(self, 'artist') self.name = name self.username = username - self.images = images + self.info = info def __repr__(self): return "pylast.Artist(%s, %s)" % ( @@ -1616,11 +1622,11 @@ class Artist(_BaseObject, _Taggable): SIZE_SMALL """ - if not self.images: - self.images = _extract_all( + if "image" not in self.info: + self.info["image"] = _extract_all( self._request(self.ws_prefix + ".getInfo", cacheable=True), "image") - return self.images[size] + return self.info["image"][size] def get_playcount(self): """Returns the number of plays on the network.""" @@ -2558,7 +2564,7 @@ class AlbumSearch(_Search): _extract(node, "artist"), _extract(node, "name"), self.network, - images=_extract_all(node, 'image')), + info={"image": _extract_all(node, "image")}), ) return seq @@ -2578,7 +2584,7 @@ class ArtistSearch(_Search): seq = [] for node in master_node.getElementsByTagName("artist"): artist = Artist(_extract(node, "name"), self.network, - images=_extract_all(node, "image")) + info={"image": _extract_all(node, "image")}) artist.listener_count = _number(_extract(node, "listeners")) seq.append(artist) diff --git a/tests/test_network.py b/tests/test_network.py index f7eec7e..e1896ea 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -322,7 +322,7 @@ class TestPyLastNetwork(PyLastTestCase): # Act results = search.get_next_page() - images = results[0].images + images = results[0].info["image"] # Assert self.assertEqual(len(images), 4) @@ -354,7 +354,7 @@ class TestPyLastNetwork(PyLastTestCase): # Act results = search.get_next_page() - images = results[0].images + images = results[0].info["image"] # Assert self.assertEqual(len(images), 5) From 60dea38d109eecf3725347e9f83213400c859fc0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 16 Apr 2018 18:31:33 +0300 Subject: [PATCH 424/768] Add assert_startswith/endswith helper functions --- tests/test_album.py | 4 ++-- tests/test_library.py | 4 ++-- tests/test_librefm.py | 2 +- tests/test_network.py | 16 ++++++++-------- tests/test_pylast.py | 6 ++++++ tests/test_user.py | 8 ++++---- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index 2c7f369..a44305b 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -107,8 +107,8 @@ class TestPyLastAlbum(PyLastTestCase): image = album.get_cover_image() # Assert - self.assertTrue(image.startswith("https://")) - self.assertTrue(image.endswith(".png")) + self.assert_startswith(image, "https://") + self.assert_endswith(image, ".png") if __name__ == '__main__': diff --git a/tests/test_library.py b/tests/test_library.py index 1e437f1..ca87c8e 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -19,7 +19,7 @@ class TestPyLastLibrary(PyLastTestCase): representation = repr(library) # Assert - self.assertTrue(representation.startswith("pylast.Library(")) + self.assert_startswith(representation, "pylast.Library(") def test_str(self): # Arrange @@ -29,7 +29,7 @@ class TestPyLastLibrary(PyLastTestCase): string = str(library) # Assert - self.assertTrue(string.endswith("'s Library")) + self.assert_endswith(string, "'s Library") def test_library_is_hashable(self): # Arrange diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 7c958cb..ab3ea3f 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -42,7 +42,7 @@ class TestPyLastWithLibreFm(unittest.TestCase): representation = repr(network) # Assert - self.assertTrue(representation.startswith("pylast.LibreFMNetwork(")) + self.assert_startswith(representation, "pylast.LibreFMNetwork(") if __name__ == '__main__': diff --git a/tests/test_network.py b/tests/test_network.py index e1896ea..e41a9fc 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -327,12 +327,12 @@ class TestPyLastNetwork(PyLastTestCase): # Assert self.assertEqual(len(images), 4) - self.assertTrue(images[pylast.SIZE_SMALL].startswith("https://")) - self.assertTrue(images[pylast.SIZE_SMALL].endswith(".png")) + self.assert_startswith(images[pylast.SIZE_SMALL], "https://") + self.assert_endswith(images[pylast.SIZE_SMALL], ".png") self.assertIn("/34s/", images[pylast.SIZE_SMALL]) - self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].startswith("https://")) - self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].endswith(".png")) + self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") + self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) def test_artist_search(self): @@ -359,12 +359,12 @@ class TestPyLastNetwork(PyLastTestCase): # Assert self.assertEqual(len(images), 5) - self.assertTrue(images[pylast.SIZE_SMALL].startswith("https://")) - self.assertTrue(images[pylast.SIZE_SMALL].endswith(".png")) + self.assert_startswith(images[pylast.SIZE_SMALL], "https://") + self.assert_endswith(images[pylast.SIZE_SMALL], ".png") self.assertIn("/34s/", images[pylast.SIZE_SMALL]) - self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].startswith("https://")) - self.assertTrue(images[pylast.SIZE_EXTRA_LARGE].endswith(".png")) + self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") + self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) def test_track_search(self): diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 9ebbcc1..c09ad27 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -52,6 +52,12 @@ class PyLastTestCase(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) + def assert_startswith(self, str, prefix, start=None, end=None): + self.assertTrue(str.startswith(prefix, start, end)) + + def assert_endswith(self, str, suffix, start=None, end=None): + self.assertTrue(str.endswith(suffix, start, end)) + def helper_is_thing_hashable(self, thing): # Arrange things = set() diff --git a/tests/test_user.py b/tests/test_user.py index c742bd3..ecb7c94 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -20,7 +20,7 @@ class TestPyLastUser(PyLastTestCase): representation = repr(user) # Assert - self.assertTrue(representation.startswith("pylast.User('RJ',")) + self.assert_startswith(representation, "pylast.User('RJ',") def test_str(self): # Arrange @@ -342,7 +342,7 @@ class TestPyLastUser(PyLastTestCase): url = user.get_image() # Assert - self.assertTrue(url.startswith("https://")) + self.assert_startswith(url, "https://") def test_user_get_library(self): # Arrange @@ -392,8 +392,8 @@ class TestPyLastUser(PyLastTestCase): image = user.get_image() # Assert - self.assertTrue(image.startswith("https://")) - self.assertTrue(image.endswith(".png")) + self.assert_startswith(image, "https://") + self.assert_endswith(image, ".png") def test_get_url(self): # Arrange From 70aad87a1be1444cf561cd7460169871a5a77c88 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 16 Apr 2018 19:02:32 +0300 Subject: [PATCH 425/768] Store track images for future use, refactor album image fetching --- pylast/__init__.py | 58 ++++++++++++++++++++++++------------------- tests/test_network.py | 21 ++++++++++++++++ 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 090cadc..a53d632 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1431,6 +1431,21 @@ class _Opus(_BaseObject, _Taggable): return self.artist + def get_cover_image(self, size=SIZE_EXTRA_LARGE): + """ + Returns a URI to the cover image + size can be one of: + SIZE_EXTRA_LARGE + SIZE_LARGE + SIZE_MEDIUM + SIZE_SMALL + """ + if "image" not in self.info: + self.info["image"] = _extract_all( + self._request(self.ws_prefix + ".getInfo", cacheable=True), + "image") + return self.info["image"][size] + def get_title(self, properly_capitalized=False): """Returns the artist or track title.""" if properly_capitalized: @@ -1499,21 +1514,6 @@ class Album(_Opus): super(Album, self).__init__(artist, title, network, "album", username, info) - def get_cover_image(self, size=SIZE_EXTRA_LARGE): - """ - Returns a URI to the cover image - size can be one of: - SIZE_EXTRA_LARGE - SIZE_LARGE - SIZE_MEDIUM - SIZE_SMALL - """ - if "image" not in self.info: - self.info["image"] = _extract_all( - self._request(self.ws_prefix + ".getInfo", cacheable=True), - "image") - return self.info["image"][size] - def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -1981,8 +1981,9 @@ class Track(_Opus): __hash__ = _Opus.__hash__ - def __init__(self, artist, title, network, username=None): - super(Track, self).__init__(artist, title, network, "track", username) + def __init__(self, artist, title, network, username=None, info=None): + super(Track, self).__init__(artist, title, network, "track", username, + info) def get_correction(self): """Returns the corrected track name.""" @@ -2560,11 +2561,13 @@ class AlbumSearch(_Search): seq = [] for node in master_node.getElementsByTagName("album"): - seq.append(Album( - _extract(node, "artist"), - _extract(node, "name"), - self.network, - info={"image": _extract_all(node, "image")}), + seq.append( + Album( + _extract(node, "artist"), + _extract(node, "name"), + self.network, + info={"image": _extract_all(node, "image")}, + ), ) return seq @@ -2583,8 +2586,11 @@ class ArtistSearch(_Search): seq = [] for node in master_node.getElementsByTagName("artist"): - artist = Artist(_extract(node, "name"), self.network, - info={"image": _extract_all(node, "image")}) + artist = Artist( + _extract(node, "name"), + self.network, + info={"image": _extract_all(node, "image")}, + ) artist.listener_count = _number(_extract(node, "listeners")) seq.append(artist) @@ -2615,7 +2621,9 @@ class TrackSearch(_Search): track = Track( _extract(node, "artist"), _extract(node, "name"), - self.network) + self.network, + info={"image": _extract_all(node, "image")}, + ) track.listener_count = _number(_extract(node, "listeners")) seq.append(track) diff --git a/tests/test_network.py b/tests/test_network.py index e41a9fc..0e55bc3 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -380,6 +380,27 @@ class TestPyLastNetwork(PyLastTestCase): self.assertIsInstance(results, list) self.assertIsInstance(results[0], pylast.Track) + def test_track_search_images(self): + # Arrange + artist = "Nirvana" + track = "Smells Like Teen Spirit" + search = self.network.search_for_track(artist, track) + + # Act + results = search.get_next_page() + images = results[0].info["image"] + + # Assert + self.assertEqual(len(images), 4) + + self.assert_startswith(images[pylast.SIZE_SMALL], "https://") + self.assert_endswith(images[pylast.SIZE_SMALL], ".png") + self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + + self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") + self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") + self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + def test_search_get_total_result_count(self): # Arrange artist = "Nirvana" From f18fd3f758951f4427a51222392ef8608de6aa6b Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 16 Apr 2018 21:09:52 +0300 Subject: [PATCH 426/768] TestPyLastWithLastFm and TestPyLastWithLibreFm now inherit from PyLastTestCase --- tests/test_album.py | 4 ++-- tests/test_artist.py | 4 ++-- tests/test_country.py | 4 ++-- tests/test_library.py | 4 ++-- tests/test_librefm.py | 4 ++-- tests/test_network.py | 4 ++-- tests/test_pylast.py | 17 ++++++++++------- tests/test_tag.py | 4 ++-- tests/test_track.py | 4 ++-- tests/test_user.py | 4 ++-- 10 files changed, 28 insertions(+), 25 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index a44305b..dd76de8 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastAlbum(PyLastTestCase): +class TestPyLastAlbum(TestPyLastWithLastFm): def test_album_tags_are_topitems(self): # Arrange diff --git a/tests/test_artist.py b/tests/test_artist.py index 58612ea..a4f2cfd 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastArtist(PyLastTestCase): +class TestPyLastArtist(TestPyLastWithLastFm): def test_repr(self): # Arrange diff --git a/tests/test_country.py b/tests/test_country.py index 4f6b25f..a755db3 100755 --- a/tests/test_country.py +++ b/tests/test_country.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastCountry(PyLastTestCase): +class TestPyLastCountry(TestPyLastWithLastFm): def test_country_is_hashable(self): # Arrange diff --git a/tests/test_library.py b/tests/test_library.py index ca87c8e..a0aceb5 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastLibrary(PyLastTestCase): +class TestPyLastLibrary(TestPyLastWithLastFm): def test_repr(self): # Arrange diff --git a/tests/test_librefm.py b/tests/test_librefm.py index ab3ea3f..a19e86a 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -8,11 +8,11 @@ from flaky import flaky import pylast -from .test_pylast import load_secrets +from .test_pylast import PyLastTestCase, load_secrets @flaky(max_runs=5, min_passes=1) -class TestPyLastWithLibreFm(unittest.TestCase): +class TestPyLastWithLibreFm(PyLastTestCase): """Own class for Libre.fm because we don't need the Last.fm setUp""" def test_libre_fm(self): diff --git a/tests/test_network.py b/tests/test_network.py index 0e55bc3..4cd1a16 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -7,10 +7,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastNetwork(PyLastTestCase): +class TestPyLastNetwork(TestPyLastWithLastFm): def test_scrobble(self): # Arrange diff --git a/tests/test_pylast.py b/tests/test_pylast.py index c09ad27..e3193a0 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -30,9 +30,18 @@ def load_secrets(): return doc -@flaky(max_runs=5, min_passes=1) class PyLastTestCase(unittest.TestCase): + def assert_startswith(self, str, prefix, start=None, end=None): + self.assertTrue(str.startswith(prefix, start, end)) + + def assert_endswith(self, str, suffix, start=None, end=None): + self.assertTrue(str.endswith(suffix, start, end)) + + +@flaky(max_runs=5, min_passes=1) +class TestPyLastWithLastFm(PyLastTestCase): + secrets = None def unix_timestamp(self): @@ -52,12 +61,6 @@ class PyLastTestCase(unittest.TestCase): api_key=API_KEY, api_secret=API_SECRET, username=self.username, password_hash=password_hash) - def assert_startswith(self, str, prefix, start=None, end=None): - self.assertTrue(str.startswith(prefix, start, end)) - - def assert_endswith(self, str, suffix, start=None, end=None): - self.assertTrue(str.endswith(suffix, start, end)) - def helper_is_thing_hashable(self, thing): # Arrange things = set() diff --git a/tests/test_tag.py b/tests/test_tag.py index e56aac5..d3980fb 100755 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastTag(PyLastTestCase): +class TestPyLastTag(TestPyLastWithLastFm): def test_tag_is_hashable(self): # Arrange diff --git a/tests/test_track.py b/tests/test_track.py index bb34f56..b68c065 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -6,10 +6,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastTrack(PyLastTestCase): +class TestPyLastTrack(TestPyLastWithLastFm): def test_love(self): # Arrange diff --git a/tests/test_user.py b/tests/test_user.py index ecb7c94..7b0e636 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -7,10 +7,10 @@ import unittest import pylast -from .test_pylast import PyLastTestCase +from .test_pylast import TestPyLastWithLastFm -class TestPyLastUser(PyLastTestCase): +class TestPyLastUser(TestPyLastWithLastFm): def test_repr(self): # Arrange From 8def14de48711cc1df6db762f51b727f86c2e055 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 16 Apr 2018 21:57:29 +0300 Subject: [PATCH 427/768] Tests no longer flaky, let's keep but reduce in case of temp net errors --- tests/test_librefm.py | 2 +- tests/test_pylast.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_librefm.py b/tests/test_librefm.py index a19e86a..1f50743 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -11,7 +11,7 @@ import pylast from .test_pylast import PyLastTestCase, load_secrets -@flaky(max_runs=5, min_passes=1) +@flaky(max_runs=3, min_passes=1) class TestPyLastWithLibreFm(PyLastTestCase): """Own class for Libre.fm because we don't need the Last.fm setUp""" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index e3193a0..aba7688 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -39,7 +39,7 @@ class PyLastTestCase(unittest.TestCase): self.assertTrue(str.endswith(suffix, start, end)) -@flaky(max_runs=5, min_passes=1) +@flaky(max_runs=3, min_passes=1) class TestPyLastWithLastFm(PyLastTestCase): secrets = None From e3537d5b86edf4b59dfe69a5a56b1cf11528d7c5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 May 2018 10:22:58 +0300 Subject: [PATCH 428/768] Switch lint to flake8 --- tox.ini | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index e5ca35d..e4006c2 100644 --- a/tox.ini +++ b/tox.ini @@ -25,13 +25,9 @@ commands = {posargs} [testenv:lint] deps = - pycodestyle - pyflakes + flake8 commands = - pyflakes pylast - pyflakes tests - pycodestyle pylast - pycodestyle tests + flake8 . [testenv:py2lint] deps = From d35daaaeba2617d3cbb7ac292a703ef9dffc6ecc Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 May 2018 10:24:28 +0300 Subject: [PATCH 429/768] Four-space indents --- tox.ini | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tox.ini b/tox.ini index e4006c2..3c933f5 100644 --- a/tox.ini +++ b/tox.ini @@ -10,13 +10,13 @@ setenv = PYLAST_API_SECRET={env:PYLAST_API_SECRET:} deps = # Workaround for yaml/pyyaml#126 - py27,py36,py35,py34,pypy,pypy3,py36dev: pyyaml - py37dev: git+https://github.com/yaml/pyyaml@master#egg=pyyaml - pytest - mock - ipdb - pytest-cov - flaky + py27,py36,py35,py34,pypy,pypy3,py36dev: pyyaml + py37dev: git+https://github.com/yaml/pyyaml@master#egg=pyyaml + pytest + mock + ipdb + pytest-cov + flaky commands = pytest -v -s -W all --cov pylast --cov-report term-missing {posargs} [testenv:venv] @@ -25,14 +25,14 @@ commands = {posargs} [testenv:lint] deps = - flake8 + flake8 commands = flake8 . [testenv:py2lint] deps = {[testenv:lint]deps} - clonedigger + clonedigger commands = {[testenv:lint]commands} ./clonedigger.sh From 7ac0cbb898de56a1d323efd14f47daedb623af70 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 May 2018 10:31:24 +0300 Subject: [PATCH 430/768] Add pep8-naming to flake8 linting --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 3c933f5..94deaf7 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ commands = {posargs} [testenv:lint] deps = flake8 + pep8-naming commands = flake8 . From 1d5e965183002f497112df369589da544b5d5578 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 May 2018 10:39:16 +0300 Subject: [PATCH 431/768] Fix pep8-naming --- pylast/__init__.py | 6 +++--- tests/test_pylast.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index a53d632..bb8a34a 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -108,6 +108,9 @@ SCROBBLE_MODE_LOVED = "L" SCROBBLE_MODE_BANNED = "B" SCROBBLE_MODE_SKIPPED = "S" +# Delay time in seconds from section 4.4 of https://www.last.fm/api/tos +DELAY_TIME = 0.2 + # Python >3.4 and >2.7.9 has sane defaults SSL_CONTEXT = ssl.create_default_context() @@ -254,9 +257,6 @@ class _Network(object): """ Makes sure that web service calls are at least 0.2 seconds apart. """ - - # Delay time in seconds from section 4.4 of https://www.last.fm/api/tos - DELAY_TIME = 0.2 now = time.time() time_since_last = now - self.last_call_time diff --git a/tests/test_pylast.py b/tests/test_pylast.py index aba7688..2bee953 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -54,11 +54,11 @@ class TestPyLastWithLastFm(PyLastTestCase): self.username = self.__class__.secrets["username"] password_hash = self.__class__.secrets["password_hash"] - API_KEY = self.__class__.secrets["api_key"] - API_SECRET = self.__class__.secrets["api_secret"] + api_key = self.__class__.secrets["api_key"] + api_secret = self.__class__.secrets["api_secret"] self.network = pylast.LastFMNetwork( - api_key=API_KEY, api_secret=API_SECRET, + api_key=api_key, api_secret=api_secret, username=self.username, password_hash=password_hash) def helper_is_thing_hashable(self, thing): From fce5e5d830ad94679a471fd007418898ed67d1b8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 11 May 2018 22:07:28 +0300 Subject: [PATCH 432/768] 'Tracks' with only one are artists not tracks, skip them --- pylast/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index bb8a34a..35cffaf 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2202,8 +2202,11 @@ class User(_BaseObject, _Chartable): self.ws_prefix + ".getLovedTracks", cacheable, params): + try: + artist = _extract(track, "name", 1) + except IndexError: + continue title = _extract(track, "name") - artist = _extract(track, "name", 1) date = _extract(track, "date") timestamp = track.getElementsByTagName( "date")[0].getAttribute("uts") From eae4b6261ccd8ff1698e5c595612380978c9e985 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 16 May 2018 11:45:44 +0300 Subject: [PATCH 433/768] Add '# pragma: no cover' as no easy way to test --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 35cffaf..5fef12f 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2204,7 +2204,7 @@ class User(_BaseObject, _Chartable): params): try: artist = _extract(track, "name", 1) - except IndexError: + except IndexError: # pragma: no cover continue title = _extract(track, "name") date = _extract(track, "date") From 30d512fd0e565db1a98ad918063aa8f71058b744 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 20 May 2018 18:41:27 +0300 Subject: [PATCH 434/768] Fix pep8-naming --- pylast/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 5fef12f..1d0c6d9 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -833,7 +833,7 @@ class _Request(object): 'User-Agent': "pylast" + '/' + __version__ } - (HOST_NAME, HOST_SUBDIR) = self.network.ws_server + (host_name, host_subdir) = self.network.ws_server if self.network.is_proxy_enabled(): conn = HTTPSConnection( @@ -843,17 +843,17 @@ class _Request(object): try: conn.request( - method='POST', url="https://" + HOST_NAME + HOST_SUBDIR, + method='POST', url="https://" + host_name + host_subdir, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) else: - conn = HTTPSConnection(context=SSL_CONTEXT, host=HOST_NAME) + conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) try: conn.request( - method='POST', url=HOST_SUBDIR, body=data, headers=headers) + method='POST', url=host_subdir, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) From b3a4ac5388cdff263f590786a25279b8dc5dd813 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 May 2018 19:23:54 +0300 Subject: [PATCH 435/768] Fix: 'list' object has no attribute 'track' --- tests/test_track.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index b68c065..53f84dd 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -40,8 +40,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert loved = lastfm_user.get_loved_tracks(limit=1) if len(loved): # OK to be empty but if not: - self.assertNotEqual(str(loved.track.artist), "Test Artist") - self.assertNotEqual(str(loved.track.title), "test title") + self.assertNotEqual(str(loved[0].track.artist), "Test Artist") + self.assertNotEqual(str(loved[0].track.title), "test title") def test_user_play_count_in_track_info(self): # Arrange From 4aefda5d39596d86e535ecc3bf82d02bfebe28ea Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 May 2018 19:28:59 +0300 Subject: [PATCH 436/768] A user without a country can now have 'None' --- pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 1d0c6d9..896f440 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2300,7 +2300,7 @@ class User(_BaseObject, _Chartable): country = _extract(doc, "country") - if country is None: + if country is None or country == "None": return None else: return Country(country, self.network) From 000993c3289405c9d5a50c896c47795a52075608 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 May 2018 20:14:13 +0300 Subject: [PATCH 437/768] Fix test_cacheable_user[_artist_tracks] --- pylast/__init__.py | 7 ++----- tests/test_pylast.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 896f440..2c316df 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -889,6 +889,7 @@ class _Request(object): raise MalformedResponseError(self.network, e) e = doc.getElementsByTagName('lfm')[0] + # logger.debug(doc.toprettyxml()) if e.getAttribute('status') != "ok": e = doc.getElementsByTagName('error')[0] @@ -2496,17 +2497,13 @@ class User(_BaseObject, _Chartable): class AuthenticatedUser(User): def __init__(self, network): - User.__init__(self, "", network) + User.__init__(self, network.username, network) def _get_params(self): return {"user": self.get_name()} def get_name(self): """Returns the name of the authenticated user.""" - - doc = self._request("user.getInfo", True, {"user": ""}) # hack - - self.name = _extract(doc, "name") return self.name diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 2bee953..5321bab 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -39,7 +39,7 @@ class PyLastTestCase(unittest.TestCase): self.assertTrue(str.endswith(suffix, start, end)) -@flaky(max_runs=3, min_passes=1) +@flaky(max_runs=1, min_passes=1) class TestPyLastWithLastFm(PyLastTestCase): secrets = None From 8bca1d2794f5c38205150adbbf3fb5190ed25da4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 May 2018 20:58:33 +0300 Subject: [PATCH 438/768] Keep flaky max_runs=3, sometimes build jobs overlap a bit --- tests/test_pylast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 5321bab..2bee953 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -39,7 +39,7 @@ class PyLastTestCase(unittest.TestCase): self.assertTrue(str.endswith(suffix, start, end)) -@flaky(max_runs=1, min_passes=1) +@flaky(max_runs=3, min_passes=1) class TestPyLastWithLastFm(PyLastTestCase): secrets = None From f3c9b4150d292ff2e7cb429ec0b7a8f55433cbcb Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 20 May 2018 18:22:14 +0300 Subject: [PATCH 439/768] Use Black's default 88 line length --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index 472423d..cc065b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,6 @@ universal = 1 [metadata] license_file = COPYING + +[pycodestyle] +max_line_length = 88 From e5a5278d51949545d419917864a6d0867d89c1eb Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 20 May 2018 18:25:42 +0300 Subject: [PATCH 440/768] Check Black on CI --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 94deaf7..89fa5d0 100644 --- a/tox.ini +++ b/tox.ini @@ -41,5 +41,7 @@ commands = [testenv:py3lint] deps = {[testenv:lint]deps} + black commands = {[testenv:lint]commands} + black --check . From da9465c100fa0e9e0644e4dd4e52842fd667eee6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 6 Jun 2018 10:19:04 +0300 Subject: [PATCH 441/768] Format with Black 18.6b1 --- pylast/__init__.py | 704 ++++++++++++++++++++++-------------------- setup.py | 19 +- tests/test_album.py | 9 +- tests/test_artist.py | 11 +- tests/test_country.py | 3 +- tests/test_library.py | 3 +- tests/test_librefm.py | 8 +- tests/test_network.py | 24 +- tests/test_pylast.py | 19 +- tests/test_tag.py | 3 +- tests/test_track.py | 11 +- tests/test_user.py | 35 ++- tests/unicode_test.py | 23 +- 13 files changed, 458 insertions(+), 414 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 2c316df..9a14011 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,12 +32,13 @@ import tempfile import time import xml.dom -__version__ = '2.3.0.dev0' -__author__ = 'Amr Hassan, hugovk, Mice Pápai' -__copyright__ = ('Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, ' - '2017 Mice Pápai') +__version__ = "2.3.0.dev0" +__author__ = "Amr Hassan, hugovk, Mice Pápai" +__copyright__ = ( + "Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, " "2017 Mice Pápai" +) __license__ = "apache2" -__email__ = 'amr.hassan@gmail.com' +__email__ = "amr.hassan@gmail.com" if sys.version_info.major == 2: @@ -48,6 +49,7 @@ else: import html.entities as htmlentitydefs from http.client import HTTPSConnection from urllib.parse import quote_plus as url_quote_plus + unichr = chr @@ -66,12 +68,12 @@ STATUS_INVALID_SIGNATURE = 13 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 -PERIOD_OVERALL = 'overall' -PERIOD_7DAYS = '7day' -PERIOD_1MONTH = '1month' -PERIOD_3MONTHS = '3month' -PERIOD_6MONTHS = '6month' -PERIOD_12MONTHS = '12month' +PERIOD_OVERALL = "overall" +PERIOD_7DAYS = "7day" +PERIOD_1MONTH = "1month" +PERIOD_3MONTHS = "3month" +PERIOD_6MONTHS = "6month" +PERIOD_12MONTHS = "12month" DOMAIN_ENGLISH = 0 DOMAIN_GERMAN = 1 @@ -125,8 +127,19 @@ class _Network(object): """ def __init__( - self, name, homepage, ws_server, api_key, api_secret, session_key, - username, password_hash, domain_names, urls, token=None): + self, + name, + homepage, + ws_server, + api_key, + api_secret, + session_key, + username, + password_hash, + domain_names, + urls, + token=None, + ): """ name: the name of the network homepage: the homepage URL @@ -173,15 +186,16 @@ class _Network(object): # Load session_key from authentication token if provided if token and not self.session_key: sk_gen = SessionKeyGenerator(self) - self.session_key = sk_gen.get_web_auth_session_key( - url=None, token=token) + self.session_key = sk_gen.get_web_auth_session_key(url=None, token=token) # Generate a session_key if necessary - if ((self.api_key and self.api_secret) and not self.session_key and - (self.username and self.password_hash)): + if ( + (self.api_key and self.api_secret) + and not self.session_key + and (self.username and self.password_hash) + ): sk_gen = SessionKeyGenerator(self) - self.session_key = sk_gen.get_session_key( - self.username, self.password_hash) + self.session_key = sk_gen.get_session_key(self.username, self.password_hash) def __str__(self): return "%s Network" % self.name @@ -245,7 +259,9 @@ class _Network(object): def _get_url(self, domain, url_type): return "https://%s/%s" % ( - self._get_language_domain(domain), self.urls[url_type]) + self._get_language_domain(domain), + self.urls[url_type], + ) def _get_ws_auth(self): """ @@ -330,8 +346,7 @@ class _Network(object): return _extract_top_artists(doc, self) - def get_geo_top_tracks( - self, country, location=None, limit=None, cacheable=True): + def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True): """Get the most popular tracks on Last.fm last week by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 @@ -469,8 +484,16 @@ class _Network(object): return Album(_extract(doc, "artist"), _extract(doc, "name"), self) def update_now_playing( - self, artist, title, album=None, album_artist=None, - duration=None, track_number=None, mbid=None, context=None): + self, + artist, + title, + album=None, + album_artist=None, + duration=None, + track_number=None, + mbid=None, + context=None, + ): """ Used to notify Last.fm that a user has started listening to a track. @@ -506,9 +529,18 @@ class _Network(object): _Request(self, "track.updateNowPlaying", params).execute() def scrobble( - self, artist, title, timestamp, album=None, album_artist=None, - track_number=None, duration=None, stream_id=None, context=None, - mbid=None): + self, + artist, + title, + timestamp, + album=None, + album_artist=None, + track_number=None, + duration=None, + stream_id=None, + context=None, + mbid=None, + ): """Used to add a track-play to a user's profile. @@ -531,11 +563,22 @@ class _Network(object): duration (Optional) : The length of the track in seconds. """ - return self.scrobble_many(({ - "artist": artist, "title": title, "timestamp": timestamp, - "album": album, "album_artist": album_artist, - "track_number": track_number, "duration": duration, - "stream_id": stream_id, "context": context, "mbid": mbid},)) + return self.scrobble_many( + ( + { + "artist": artist, + "title": title, + "timestamp": timestamp, + "album": album, + "album_artist": album_artist, + "track_number": track_number, + "duration": duration, + "stream_id": stream_id, + "context": context, + "mbid": mbid, + }, + ) + ) def scrobble_many(self, tracks): """ @@ -557,12 +600,20 @@ class _Network(object): params["track[%d]" % i] = tracks_to_scrobble[i]["title"] additional_args = ( - "timestamp", "album", "album_artist", "context", - "stream_id", "track_number", "mbid", "duration") + "timestamp", + "album", + "album_artist", + "context", + "stream_id", + "track_number", + "mbid", + "duration", + ) args_map_to = { # so friggin lazy "album_artist": "albumArtist", "track_number": "trackNumber", - "stream_id": "streamID"} + "stream_id": "streamID", + } for arg in additional_args: @@ -572,8 +623,7 @@ class _Network(object): else: maps_to = arg - params[ - "%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg] + params["%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg] _Request(self, "track.scrobble", params).execute() @@ -604,8 +654,14 @@ class LastFMNetwork(_Network): """ def __init__( - self, api_key="", api_secret="", session_key="", username="", - password_hash="", token=""): + self, + api_key="", + api_secret="", + session_key="", + username="", + password_hash="", + token="", + ): _Network.__init__( self, name="Last.fm", @@ -618,18 +674,18 @@ class LastFMNetwork(_Network): password_hash=password_hash, token=token, domain_names={ - DOMAIN_ENGLISH: 'www.last.fm', - DOMAIN_GERMAN: 'www.last.fm/de', - DOMAIN_SPANISH: 'www.last.fm/es', - DOMAIN_FRENCH: 'www.last.fm/fr', - DOMAIN_ITALIAN: 'www.last.fm/it', - DOMAIN_POLISH: 'www.last.fm/pl', - DOMAIN_PORTUGUESE: 'www.last.fm/pt', - DOMAIN_SWEDISH: 'www.last.fm/sv', - DOMAIN_TURKISH: 'www.last.fm/tr', - DOMAIN_RUSSIAN: 'www.last.fm/ru', - DOMAIN_JAPANESE: 'www.last.fm/ja', - DOMAIN_CHINESE: 'www.last.fm/zh', + DOMAIN_ENGLISH: "www.last.fm", + DOMAIN_GERMAN: "www.last.fm/de", + DOMAIN_SPANISH: "www.last.fm/es", + DOMAIN_FRENCH: "www.last.fm/fr", + DOMAIN_ITALIAN: "www.last.fm/it", + DOMAIN_POLISH: "www.last.fm/pl", + DOMAIN_PORTUGUESE: "www.last.fm/pt", + DOMAIN_SWEDISH: "www.last.fm/sv", + DOMAIN_TURKISH: "www.last.fm/tr", + DOMAIN_RUSSIAN: "www.last.fm/ru", + DOMAIN_JAPANESE: "www.last.fm/ja", + DOMAIN_CHINESE: "www.last.fm/zh", }, urls={ "album": "music/%(artist)s/%(album)s", @@ -638,16 +694,21 @@ class LastFMNetwork(_Network): "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "user": "user/%(name)s", - } + }, ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" % (", ".join( - ("'%s'" % self.api_key, - "'%s'" % self.api_secret, - "'%s'" % self.session_key, - "'%s'" % self.username, - "'%s'" % self.password_hash))) + return "pylast.LastFMNetwork(%s)" % ( + ", ".join( + ( + "'%s'" % self.api_key, + "'%s'" % self.api_secret, + "'%s'" % self.session_key, + "'%s'" % self.username, + "'%s'" % self.password_hash, + ) + ) + ) class LibreFMNetwork(_Network): @@ -666,8 +727,8 @@ class LibreFMNetwork(_Network): """ def __init__( - self, api_key="", api_secret="", session_key="", username="", - password_hash=""): + self, api_key="", api_secret="", session_key="", username="", password_hash="" + ): _Network.__init__( self, @@ -700,20 +761,26 @@ class LibreFMNetwork(_Network): "tag": "tag/%(name)s", "track": "music/%(artist)s/_/%(title)s", "user": "user/%(name)s", - } + }, ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" % (", ".join( - ("'%s'" % self.api_key, - "'%s'" % self.api_secret, - "'%s'" % self.session_key, - "'%s'" % self.username, - "'%s'" % self.password_hash))) + return "pylast.LibreFMNetwork(%s)" % ( + ", ".join( + ( + "'%s'" % self.api_key, + "'%s'" % self.api_secret, + "'%s'" % self.session_key, + "'%s'" % self.username, + "'%s'" % self.password_hash, + ) + ) + ) class _ShelfCacheBackend(object): """Used as a backend for caching cacheable requests.""" + def __init__(self, file_path=None): self.shelf = shelve.open(file_path) self.cache_keys = set(self.shelf.keys()) @@ -747,8 +814,7 @@ class _Request(object): for key in params: self.params[key] = _unicode(params[key]) - (self.api_key, self.api_secret, self.session_key) = \ - network._get_ws_auth() + (self.api_key, self.api_secret, self.session_key) = network._get_ws_auth() self.params["api_key"] = self.api_key self.params["method"] = method_name @@ -764,7 +830,7 @@ class _Request(object): """Sign this request.""" if "api_sig" not in self.params.keys(): - self.params['api_sig'] = self._get_signature() + self.params["api_sig"] = self._get_signature() def _get_signature(self): """ @@ -823,14 +889,13 @@ class _Request(object): data = [] for name in self.params.keys(): - data.append('='.join(( - name, url_quote_plus(_string(self.params[name]))))) - data = '&'.join(data) + data.append("=".join((name, url_quote_plus(_string(self.params[name]))))) + data = "&".join(data) headers = { "Content-type": "application/x-www-form-urlencoded", - 'Accept-Charset': 'utf-8', - 'User-Agent': "pylast" + '/' + __version__ + "Accept-Charset": "utf-8", + "User-Agent": "pylast" + "/" + __version__, } (host_name, host_subdir) = self.network.ws_server @@ -839,12 +904,16 @@ class _Request(object): conn = HTTPSConnection( context=SSL_CONTEXT, host=self.network._get_proxy()[0], - port=self.network._get_proxy()[1]) + port=self.network._get_proxy()[1], + ) try: conn.request( - method='POST', url="https://" + host_name + host_subdir, - body=data, headers=headers) + method="POST", + url="https://" + host_name + host_subdir, + body=data, + headers=headers, + ) except Exception as e: raise NetworkError(self.network, e) @@ -852,8 +921,7 @@ class _Request(object): conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) try: - conn.request( - method='POST', url=host_subdir, body=data, headers=headers) + conn.request(method="POST", url=host_subdir, body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -876,24 +944,22 @@ class _Request(object): else: response = self._download_response() - return minidom.parseString(_string(response).replace( - "opensearch:", "")) + return minidom.parseString(_string(response).replace("opensearch:", "")) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" try: - doc = minidom.parseString(_string(response).replace( - "opensearch:", "")) + doc = minidom.parseString(_string(response).replace("opensearch:", "")) except Exception as e: raise MalformedResponseError(self.network, e) - e = doc.getElementsByTagName('lfm')[0] + e = doc.getElementsByTagName("lfm")[0] # logger.debug(doc.toprettyxml()) - if e.getAttribute('status') != "ok": - e = doc.getElementsByTagName('error')[0] - status = e.getAttribute('code') + if e.getAttribute("status") != "ok": + e = doc.getElementsByTagName("error")[0] + status = e.getAttribute("code") details = e.firstChild.data.strip() raise WSError(self.network, status, details) @@ -933,7 +999,7 @@ class SessionKeyGenerator(object): session. """ - request = _Request(self.network, 'auth.getToken') + request = _Request(self.network, "auth.getToken") # default action is that a request is signed only when # a session key is provided. @@ -941,7 +1007,7 @@ class SessionKeyGenerator(object): doc = request.execute() - e = doc.getElementsByTagName('token')[0] + e = doc.getElementsByTagName("token")[0] return e.firstChild.data def get_web_auth_url(self): @@ -952,9 +1018,11 @@ class SessionKeyGenerator(object): token = self._get_web_auth_token() - url = '%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s' % \ - {"homepage": self.network.homepage, - "api": self.network.api_key, "token": token} + url = "%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s" % { + "homepage": self.network.homepage, + "api": self.network.api_key, + "token": token, + } self.web_auth_tokens[url] = token @@ -971,7 +1039,7 @@ class SessionKeyGenerator(object): # This will raise a WSError if token is blank or unauthorized token = token - request = _Request(self.network, 'auth.getSession', {'token': token}) + request = _Request(self.network, "auth.getSession", {"token": token}) # default action is that a request is signed only when # a session key is provided. @@ -979,7 +1047,7 @@ class SessionKeyGenerator(object): doc = request.execute() - return doc.getElementsByTagName('key')[0].firstChild.data + return doc.getElementsByTagName("key")[0].firstChild.data def get_session_key(self, username, password_hash): """ @@ -987,8 +1055,7 @@ class SessionKeyGenerator(object): password. """ - params = { - "username": username, "authToken": md5(username + password_hash)} + params = {"username": username, "authToken": md5(username + password_hash)} request = _Request(self.network, "auth.getMobileSession", params) # default action is that a request is signed only when @@ -1002,18 +1069,17 @@ class SessionKeyGenerator(object): TopItem = collections.namedtuple("TopItem", ["item", "weight"]) SimilarItem = collections.namedtuple("SimilarItem", ["item", "match"]) -LibraryItem = collections.namedtuple( - "LibraryItem", ["item", "playcount", "tagcount"]) +LibraryItem = collections.namedtuple("LibraryItem", ["item", "playcount", "tagcount"]) PlayedTrack = collections.namedtuple( - "PlayedTrack", ["track", "album", "playback_date", "timestamp"]) -LovedTrack = collections.namedtuple( - "LovedTrack", ["track", "date", "timestamp"]) + "PlayedTrack", ["track", "album", "playback_date", "timestamp"] +) +LovedTrack = collections.namedtuple("LovedTrack", ["track", "date", "timestamp"]) ImageSizes = collections.namedtuple( - "ImageSizes", [ - "original", "large", "largesquare", "medium", "small", "extralarge"]) + "ImageSizes", ["original", "large", "largesquare", "medium", "small", "extralarge"] +) Image = collections.namedtuple( - "Image", [ - "title", "url", "dateadded", "format", "owner", "sizes", "votes"]) + "Image", ["title", "url", "dateadded", "format", "owner", "sizes", "votes"] +) def _string_output(func): @@ -1047,34 +1113,29 @@ class _BaseObject(object): # Convert any ints (or whatever) into strings values = map(six.text_type, self._get_params().values()) - return hash(self.network) + hash(six.text_type(type(self)) + "".join( - list(self._get_params().keys()) + list(values) - ).lower()) + return hash(self.network) + hash( + six.text_type(type(self)) + + "".join(list(self._get_params().keys()) + list(values)).lower() + ) def _extract_cdata_from_request(self, method_name, tag_name, params): doc = self._request(method_name, True, params) - return doc.getElementsByTagName( - tag_name)[0].firstChild.wholeText.strip() + return doc.getElementsByTagName(tag_name)[0].firstChild.wholeText.strip() - def _get_things( - self, method, thing, thing_type, params=None, cacheable=True): + def _get_things(self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" limit = params.get("limit", 1) seq = [] for node in _collect_nodes( - limit, - self, - self.ws_prefix + "." + method, - cacheable, - params): + limit, self, self.ws_prefix + "." + method, cacheable, params + ): title = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _number(_extract(node, "playcount")) - seq.append(TopItem( - thing_type(artist, title, self.network), playcount)) + seq.append(TopItem(thing_type(artist, title, self.network), playcount)) return seq @@ -1172,20 +1233,16 @@ class _Chartable(object): params["from"] = from_date params["to"] = to_date - doc = self._request( - self.ws_prefix + method, True, params) + doc = self._request(self.ws_prefix + method, True, params) seq = [] for node in doc.getElementsByTagName(chart_kind.lower()): if chart_kind == "artist": - item = chart_type( - _extract(node, "name"), - self.network) + item = chart_type(_extract(node, "name"), self.network) else: item = chart_type( - _extract(node, "artist"), - _extract(node, "name"), - self.network) + _extract(node, "artist"), _extract(node, "name"), self.network + ) weight = _number(_extract(node, "playcount")) seq.append(TopItem(item, weight)) @@ -1215,9 +1272,9 @@ class _Taggable(object): tag = tag.get_name() params = self._get_params() - params['tags'] = tag + params["tags"] = tag - self._request(self.ws_prefix + '.addTags', False, params) + self._request(self.ws_prefix + ".addTags", False, params) def remove_tag(self, tag): """Remove a user's tag from this object.""" @@ -1226,9 +1283,9 @@ class _Taggable(object): tag = tag.get_name() params = self._get_params() - params['tag'] = tag + params["tag"] = tag - self._request(self.ws_prefix + '.removeTag', False, params) + self._request(self.ws_prefix + ".removeTag", False, params) def get_tags(self): """Returns a list of the tags set by the user to this object.""" @@ -1236,8 +1293,8 @@ class _Taggable(object): # Uncacheable because it can be dynamically changed by the user. params = self._get_params() - doc = self._request(self.ws_prefix + '.getTags', False, params) - tag_names = _extract_all(doc, 'name') + doc = self._request(self.ws_prefix + ".getTags", False, params) + tag_names = _extract_all(doc, "name") tags = [] for tag in tag_names: tags.append(Tag(tag, self.network)) @@ -1294,14 +1351,14 @@ class _Taggable(object): def get_top_tags(self, limit=None): """Returns a list of the most frequently used Tags on this object.""" - doc = self._request(self.ws_prefix + '.getTopTags', True) + doc = self._request(self.ws_prefix + ".getTopTags", True) - elements = doc.getElementsByTagName('tag') + elements = doc.getElementsByTagName("tag") seq = [] for element in elements: - tag_name = _extract(element, 'name') - tagcount = _extract(element, 'count') + tag_name = _extract(element, "name") + tagcount = _extract(element, "count") seq.append(TopItem(Tag(tag_name, self.network), tagcount)) @@ -1352,7 +1409,8 @@ class MalformedResponseError(Exception): def __str__(self): return "Malformed response from {}. Underlying error: {}".format( - self.network.name, str(self.underlying_error)) + self.network.name, str(self.underlying_error) + ) class NetworkError(Exception): @@ -1375,8 +1433,7 @@ class _Opus(_BaseObject, _Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, artist, title, network, ws_prefix, username=None, - info=None): + def __init__(self, artist, title, network, ws_prefix, username=None, info=None): """ Create an opus instance. # Parameters: @@ -1402,13 +1459,15 @@ class _Opus(_BaseObject, _Taggable): def __repr__(self): return "pylast.%s(%s, %s, %s)" % ( - self.ws_prefix.title(), repr(self.artist.name), - repr(self.title), repr(self.network)) + self.ws_prefix.title(), + repr(self.artist.name), + repr(self.title), + repr(self.network), + ) @_string_output def __str__(self): - return _unicode("%s - %s") % ( - self.get_artist().get_name(), self.get_title()) + return _unicode("%s - %s") % (self.get_artist().get_name(), self.get_title()) def __eq__(self, other): if type(self) != type(other): @@ -1424,8 +1483,9 @@ class _Opus(_BaseObject, _Taggable): def _get_params(self): return { - 'artist': self.get_artist().get_name(), - self.ws_prefix: self.get_title()} + "artist": self.get_artist().get_name(), + self.ws_prefix: self.get_title(), + } def get_artist(self): """Returns the associated Artist object.""" @@ -1443,15 +1503,16 @@ class _Opus(_BaseObject, _Taggable): """ if "image" not in self.info: self.info["image"] = _extract_all( - self._request(self.ws_prefix + ".getInfo", cacheable=True), - "image") + self._request(self.ws_prefix + ".getInfo", cacheable=True), "image" + ) return self.info["image"][size] def get_title(self, properly_capitalized=False): """Returns the artist or track title.""" if properly_capitalized: self.title = _extract( - self._request(self.ws_prefix + ".getInfo", True), "name") + self._request(self.ws_prefix + ".getInfo", True), "name" + ) return self.title @@ -1463,9 +1524,11 @@ class _Opus(_BaseObject, _Taggable): def get_playcount(self): """Returns the number of plays on the network""" - return _number(_extract( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), "playcount")) + return _number( + _extract( + self._request(self.ws_prefix + ".getInfo", cacheable=True), "playcount" + ) + ) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1474,7 +1537,7 @@ class _Opus(_BaseObject, _Taggable): return params = self._get_params() - params['username'] = self.username + params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) return _number(_extract(doc, "userplaycount")) @@ -1482,9 +1545,11 @@ class _Opus(_BaseObject, _Taggable): def get_listener_count(self): """Returns the number of listeners on the network""" - return _number(_extract( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), "listeners")) + return _number( + _extract( + self._request(self.ws_prefix + ".getInfo", cacheable=True), "listeners" + ) + ) def get_mbid(self): """Returns the MusicBrainz ID of the album or track.""" @@ -1492,7 +1557,7 @@ class _Opus(_BaseObject, _Taggable): doc = self._request(self.ws_prefix + ".getInfo", cacheable=True) try: - lfm = doc.getElementsByTagName('lfm')[0] + lfm = doc.getElementsByTagName("lfm")[0] opus = next(self._get_children_by_tag_name(lfm, self.ws_prefix)) mbid = next(self._get_children_by_tag_name(opus, "mbid")) return mbid.firstChild.nodeValue @@ -1501,8 +1566,9 @@ class _Opus(_BaseObject, _Taggable): def _get_children_by_tag_name(self, node, tag_name): for child in node.childNodes: - if (child.nodeType == child.ELEMENT_NODE and - (tag_name == '*' or child.tagName == tag_name)): + if child.nodeType == child.ELEMENT_NODE and ( + tag_name == "*" or child.tagName == tag_name + ): yield child @@ -1512,15 +1578,14 @@ class Album(_Opus): __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None): - super(Album, self).__init__(artist, title, network, "album", username, - info) + super(Album, self).__init__(artist, title, network, "album", username, info) def get_tracks(self): """Returns the list of Tracks on this album.""" return _extract_tracks( - self._request( - self.ws_prefix + ".getInfo", cacheable=True), self.network) + self._request(self.ws_prefix + ".getInfo", cacheable=True), self.network + ) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the album or track page on the network. @@ -1543,9 +1608,10 @@ class Album(_Opus): artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) - return self.network._get_url( - domain_name, self.ws_prefix) % { - 'artist': artist, 'album': title} + return self.network._get_url(domain_name, self.ws_prefix) % { + "artist": artist, + "album": title, + } class Artist(_BaseObject, _Taggable): @@ -1565,16 +1631,15 @@ class Artist(_BaseObject, _Taggable): if info is None: info = {} - _BaseObject.__init__(self, network, 'artist') - _Taggable.__init__(self, 'artist') + _BaseObject.__init__(self, network, "artist") + _Taggable.__init__(self, "artist") self.name = name self.username = username self.info = info def __repr__(self): - return "pylast.Artist(%s, %s)" % ( - repr(self.get_name()), repr(self.network)) + return "pylast.Artist(%s, %s)" % (repr(self.get_name()), repr(self.network)) def __unicode__(self): return six.text_type(self.get_name()) @@ -1602,15 +1667,15 @@ class Artist(_BaseObject, _Taggable): if properly_capitalized: self.name = _extract( - self._request(self.ws_prefix + ".getInfo", True), "name") + self._request(self.ws_prefix + ".getInfo", True), "name" + ) return self.name def get_correction(self): """Returns the corrected artist name.""" - return _extract( - self._request(self.ws_prefix + ".getCorrection"), "name") + return _extract(self._request(self.ws_prefix + ".getCorrection"), "name") def get_cover_image(self, size=SIZE_EXTRA_LARGE): """ @@ -1625,15 +1690,16 @@ class Artist(_BaseObject, _Taggable): if "image" not in self.info: self.info["image"] = _extract_all( - self._request(self.ws_prefix + ".getInfo", cacheable=True), - "image") + self._request(self.ws_prefix + ".getInfo", cacheable=True), "image" + ) return self.info["image"][size] def get_playcount(self): """Returns the number of plays on the network.""" - return _number(_extract( - self._request(self.ws_prefix + ".getInfo", True), "playcount")) + return _number( + _extract(self._request(self.ws_prefix + ".getInfo", True), "playcount") + ) def get_userplaycount(self): """Returns the number of plays by a given username""" @@ -1642,7 +1708,7 @@ class Artist(_BaseObject, _Taggable): return params = self._get_params() - params['username'] = self.username + params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) return _number(_extract(doc, "userplaycount")) @@ -1660,15 +1726,19 @@ class Artist(_BaseObject, _Taggable): if hasattr(self, "listener_count"): return self.listener_count else: - self.listener_count = _number(_extract( - self._request(self.ws_prefix + ".getInfo", True), "listeners")) + self.listener_count = _number( + _extract(self._request(self.ws_prefix + ".getInfo", True), "listeners") + ) return self.listener_count def is_streamable(self): """Returns True if the artist is streamable.""" - return bool(_number(_extract( - self._request(self.ws_prefix + ".getInfo", True), "streamable"))) + return bool( + _number( + _extract(self._request(self.ws_prefix + ".getInfo", True), "streamable") + ) + ) def get_bio(self, section, language=None): """ @@ -1683,7 +1753,8 @@ class Artist(_BaseObject, _Taggable): params = None return self._extract_cdata_from_request( - self.ws_prefix + ".getInfo", section, params) + self.ws_prefix + ".getInfo", section, params + ) def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" @@ -1702,17 +1773,18 @@ class Artist(_BaseObject, _Taggable): params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request(self.ws_prefix + '.getSimilar', True, params) + doc = self._request(self.ws_prefix + ".getSimilar", True, params) names = _extract_all(doc, "name") matches = _extract_all(doc, "match") artists = [] for i in range(0, len(names)): - artists.append(SimilarItem( - Artist(names[i], self.network), _number(matches[i]))) + artists.append( + SimilarItem(Artist(names[i], self.network), _number(matches[i])) + ) return artists @@ -1720,19 +1792,17 @@ class Artist(_BaseObject, _Taggable): """Returns a list of the top albums.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - return self._get_things( - "getTopAlbums", "album", Album, params, cacheable) + return self._get_things("getTopAlbums", "album", Album, params, cacheable) def get_top_tracks(self, limit=None, cacheable=True): """Returns a list of the most played Tracks by this artist.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - return self._get_things( - "getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. @@ -1754,8 +1824,7 @@ class Artist(_BaseObject, _Taggable): artist = _url_safe(self.get_name()) - return self.network._get_url( - domain_name, "artist") % {'artist': artist} + return self.network._get_url(domain_name, "artist") % {"artist": artist} class Country(_BaseObject): @@ -1784,7 +1853,7 @@ class Country(_BaseObject): return not self == other def _get_params(self): # TODO can move to _BaseObject - return {'country': self.get_name()} + return {"country": self.get_name()} def get_name(self): """Returns the country name. """ @@ -1795,9 +1864,9 @@ class Country(_BaseObject): """Returns a sequence of the most played artists.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request('geo.getTopArtists', cacheable, params) + doc = self._request("geo.getTopArtists", cacheable, params) return _extract_top_artists(doc, self) @@ -1805,10 +1874,9 @@ class Country(_BaseObject): """Returns a sequence of the most played tracks""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - return self._get_things( - "getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. @@ -1829,8 +1897,9 @@ class Country(_BaseObject): country_name = _url_safe(self.get_name()) - return self.network._get_url( - domain_name, "country") % {'country_name': country_name} + return self.network._get_url(domain_name, "country") % { + "country_name": country_name + } class Library(_BaseObject): @@ -1841,7 +1910,7 @@ class Library(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, user, network): - _BaseObject.__init__(self, network, 'library') + _BaseObject.__init__(self, network, "library") if isinstance(user, User): self.user = user @@ -1856,7 +1925,7 @@ class Library(_BaseObject): return repr(self.get_user()) + "'s Library" def _get_params(self): - return {'user': self.user.get_name()} + return {"user": self.user.get_name()} def get_user(self): """Returns the user who owns this library.""" @@ -1870,17 +1939,14 @@ class Library(_BaseObject): seq = [] for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getArtists", - cacheable): + limit, self, self.ws_prefix + ".getArtists", cacheable + ): name = _extract(node, "name") playcount = _number(_extract(node, "playcount")) tagcount = _number(_extract(node, "tagcount")) - seq.append(LibraryItem( - Artist(name, self.network), playcount, tagcount)) + seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) return seq @@ -1893,8 +1959,8 @@ class Tag(_BaseObject, _Chartable): __hash__ = _BaseObject.__hash__ def __init__(self, name, network): - _BaseObject.__init__(self, network, 'tag') - _Chartable.__init__(self, 'tag') + _BaseObject.__init__(self, network, "tag") + _Chartable.__init__(self, "tag") self.name = name @@ -1919,7 +1985,8 @@ class Tag(_BaseObject, _Chartable): if properly_capitalized: self.name = _extract( - self._request(self.ws_prefix + ".getInfo", True), "name") + self._request(self.ws_prefix + ".getInfo", True), "name" + ) return self.name @@ -1927,10 +1994,9 @@ class Tag(_BaseObject, _Chartable): """Returns a list of the top albums.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request( - self.ws_prefix + '.getTopAlbums', cacheable, params) + doc = self._request(self.ws_prefix + ".getTopAlbums", cacheable, params) return _extract_top_albums(doc, self.network) @@ -1938,20 +2004,18 @@ class Tag(_BaseObject, _Chartable): """Returns a list of the most played Tracks for this tag.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - return self._get_things( - "getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable) def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request( - self.ws_prefix + '.getTopArtists', cacheable, params) + doc = self._request(self.ws_prefix + ".getTopArtists", cacheable, params) return _extract_top_artists(doc, self.network) @@ -1974,7 +2038,7 @@ class Tag(_BaseObject, _Chartable): name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "tag") % {'name': name} + return self.network._get_url(domain_name, "tag") % {"name": name} class Track(_Opus): @@ -1983,14 +2047,12 @@ class Track(_Opus): __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None): - super(Track, self).__init__(artist, title, network, "track", username, - info) + super(Track, self).__init__(artist, title, network, "track", username, info) def get_correction(self): """Returns the corrected track name.""" - return _extract( - self._request(self.ws_prefix + ".getCorrection"), "name") + return _extract(self._request(self.ws_prefix + ".getCorrection"), "name") def get_duration(self): """Returns the track duration.""" @@ -2006,7 +2068,7 @@ class Track(_Opus): return params = self._get_params() - params['username'] = self.username + params["username"] = self.username doc = self._request(self.ws_prefix + ".getInfo", True, params) loved = _number(_extract(doc, "userloved")) @@ -2022,8 +2084,9 @@ class Track(_Opus): """Returns True if the full track is available for streaming.""" doc = self._request(self.ws_prefix + ".getInfo", True) - return doc.getElementsByTagName( - "streamable")[0].getAttribute("fulltrack") == "1" + return ( + doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" + ) def get_album(self): """Returns the album object of this track.""" @@ -2036,18 +2099,17 @@ class Track(_Opus): return node = doc.getElementsByTagName("album")[0] - return Album( - _extract(node, "artist"), _extract(node, "title"), self.network) + return Album(_extract(node, "artist"), _extract(node, "title"), self.network) def love(self): """Adds the track to the user's loved tracks. """ - self._request(self.ws_prefix + '.love') + self._request(self.ws_prefix + ".love") def unlove(self): """Remove the track to the user's loved tracks. """ - self._request(self.ws_prefix + '.unlove') + self._request(self.ws_prefix + ".unlove") def get_similar(self, limit=None): """ @@ -2057,14 +2119,14 @@ class Track(_Opus): params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request(self.ws_prefix + '.getSimilar', True, params) + doc = self._request(self.ws_prefix + ".getSimilar", True, params) seq = [] for node in doc.getElementsByTagName(self.ws_prefix): - title = _extract(node, 'name') - artist = _extract(node, 'name', 1) + title = _extract(node, "name") + artist = _extract(node, "name", 1) match = _number(_extract(node, "match")) seq.append(SimilarItem(Track(artist, title, self.network), match)) @@ -2092,9 +2154,10 @@ class Track(_Opus): artist = _url_safe(self.get_artist().get_name()) title = _url_safe(self.get_title()) - return self.network._get_url( - domain_name, self.ws_prefix) % { - 'artist': artist, 'title': title} + return self.network._get_url(domain_name, self.ws_prefix) % { + "artist": artist, + "title": title, + } class User(_BaseObject, _Chartable): @@ -2105,8 +2168,8 @@ class User(_BaseObject, _Chartable): __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network): - _BaseObject.__init__(self, network, 'user') - _Chartable.__init__(self, 'user') + _BaseObject.__init__(self, network, "user") + _Chartable.__init__(self, "user") self.name = user_name @@ -2134,7 +2197,8 @@ class User(_BaseObject, _Chartable): if properly_capitalized: self.name = _extract( - self._request(self.ws_prefix + ".getInfo", True), "name") + self._request(self.ws_prefix + ".getInfo", True), "name" + ) return self.name @@ -2147,24 +2211,21 @@ class User(_BaseObject, _Chartable): # "Can be limited to specific timeranges, defaults to all time." params = self._get_params() - params['artist'] = artist + params["artist"] = artist seq = [] for track in _collect_nodes( - None, - self, - self.ws_prefix + ".getArtistTracks", - cacheable, - params): + None, self, self.ws_prefix + ".getArtistTracks", cacheable, params + ): title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") album = _extract(track, "album") - timestamp = track.getElementsByTagName( - "date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(PlayedTrack( - Track(artist, title, self.network), album, date, timestamp)) + seq.append( + PlayedTrack(Track(artist, title, self.network), album, date, timestamp) + ) return seq @@ -2173,10 +2234,8 @@ class User(_BaseObject, _Chartable): seq = [] for node in _collect_nodes( - limit, - self, - self.ws_prefix + ".getFriends", - cacheable): + limit, self, self.ws_prefix + ".getFriends", cacheable + ): seq.append(User(_extract(node, "name"), self.network)) return seq @@ -2194,26 +2253,21 @@ class User(_BaseObject, _Chartable): params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit seq = [] for track in _collect_nodes( - limit, - self, - self.ws_prefix + ".getLovedTracks", - cacheable, - params): + limit, self, self.ws_prefix + ".getLovedTracks", cacheable, params + ): try: artist = _extract(track, "name", 1) except IndexError: # pragma: no cover continue title = _extract(track, "name") date = _extract(track, "date") - timestamp = track.getElementsByTagName( - "date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(LovedTrack( - Track(artist, title, self.network), date, timestamp)) + seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) return seq @@ -2223,27 +2277,26 @@ class User(_BaseObject, _Chartable): """ params = self._get_params() - params['limit'] = '1' + params["limit"] = "1" - doc = self._request(self.ws_prefix + '.getRecentTracks', False, params) + doc = self._request(self.ws_prefix + ".getRecentTracks", False, params) - tracks = doc.getElementsByTagName('track') + tracks = doc.getElementsByTagName("track") if len(tracks) == 0: return None e = tracks[0] - if not e.hasAttribute('nowplaying'): + if not e.hasAttribute("nowplaying"): return None - artist = _extract(e, 'artist') - title = _extract(e, 'name') + artist = _extract(e, "artist") + title = _extract(e, "name") return Track(artist, title, self.network, self.name) - def get_recent_tracks(self, limit=10, cacheable=True, - time_from=None, time_to=None): + def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. @@ -2265,32 +2318,29 @@ class User(_BaseObject, _Chartable): params = self._get_params() if limit: - params['limit'] = limit + params["limit"] = limit if time_from: - params['from'] = time_from + params["from"] = time_from if time_to: - params['to'] = time_to + params["to"] = time_to seq = [] for track in _collect_nodes( - limit, - self, - self.ws_prefix + ".getRecentTracks", - cacheable, - params): + limit, self, self.ws_prefix + ".getRecentTracks", cacheable, params + ): - if track.hasAttribute('nowplaying'): + if track.hasAttribute("nowplaying"): continue # to prevent the now playing track from sneaking in title = _extract(track, "name") artist = _extract(track, "artist") date = _extract(track, "date") album = _extract(track, "album") - timestamp = track.getElementsByTagName( - "date")[0].getAttribute("uts") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(PlayedTrack( - Track(artist, title, self.network), album, date, timestamp)) + seq.append( + PlayedTrack(Track(artist, title, self.network), album, date, timestamp) + ) return seq @@ -2332,46 +2382,42 @@ class User(_BaseObject, _Chartable): doc = self._request(self.ws_prefix + ".getInfo", True) - return int(doc.getElementsByTagName( - "registered")[0].getAttribute("unixtime")) + return int(doc.getElementsByTagName("registered")[0].getAttribute("unixtime")) def get_tagged_albums(self, tag, limit=None, cacheable=True): """Returns the albums tagged by a user.""" params = self._get_params() - params['tag'] = tag - params['taggingtype'] = 'album' + params["tag"] = tag + params["taggingtype"] = "album" if limit: - params['limit'] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, - params) + params["limit"] = limit + doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params) return _extract_albums(doc, self.network) def get_tagged_artists(self, tag, limit=None): """Returns the artists tagged by a user.""" params = self._get_params() - params['tag'] = tag - params['taggingtype'] = 'artist' + params["tag"] = tag + params["taggingtype"] = "artist" if limit: params["limit"] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', True, params) + doc = self._request(self.ws_prefix + ".getpersonaltags", True, params) return _extract_artists(doc, self.network) def get_tagged_tracks(self, tag, limit=None, cacheable=True): """Returns the tracks tagged by a user.""" params = self._get_params() - params['tag'] = tag - params['taggingtype'] = 'track' + params["tag"] = tag + params["taggingtype"] = "track" if limit: - params['limit'] = limit - doc = self._request(self.ws_prefix + '.getpersonaltags', cacheable, - params) + params["limit"] = limit + doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params) return _extract_tracks(doc, self.network) - def get_top_albums( - self, period=PERIOD_OVERALL, limit=None, cacheable=True): + def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -2383,12 +2429,11 @@ class User(_BaseObject, _Chartable): """ params = self._get_params() - params['period'] = period + params["period"] = period if limit: - params['limit'] = limit + params["limit"] = limit - doc = self._request( - self.ws_prefix + '.getTopAlbums', cacheable, params) + doc = self._request(self.ws_prefix + ".getTopAlbums", cacheable, params) return _extract_top_albums(doc, self.network) @@ -2404,11 +2449,11 @@ class User(_BaseObject, _Chartable): """ params = self._get_params() - params['period'] = period + params["period"] = period if limit: params["limit"] = limit - doc = self._request(self.ws_prefix + '.getTopArtists', True, params) + doc = self._request(self.ws_prefix + ".getTopArtists", True, params) return _extract_top_artists(doc, self.network) @@ -2428,14 +2473,15 @@ class User(_BaseObject, _Chartable): seq = [] for node in doc.getElementsByTagName("tag"): - seq.append(TopItem( - Tag(_extract(node, "name"), self.network), - _extract(node, "count"))) + seq.append( + TopItem( + Tag(_extract(node, "name"), self.network), _extract(node, "count") + ) + ) return seq - def get_top_tracks( - self, period=PERIOD_OVERALL, limit=None, cacheable=True): + def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -2447,12 +2493,11 @@ class User(_BaseObject, _Chartable): """ params = self._get_params() - params['period'] = period + params["period"] = period if limit: - params['limit'] = limit + params["limit"] = limit - return self._get_things( - "getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable) def get_image(self, size=SIZE_EXTRA_LARGE): """ @@ -2487,7 +2532,7 @@ class User(_BaseObject, _Chartable): name = _url_safe(self.get_name()) - return self.network._get_url(domain_name, "user") % {'name': name} + return self.network._get_url(domain_name, "user") % {"name": name} def get_library(self): """Returns the associated Library object. """ @@ -2567,7 +2612,7 @@ class AlbumSearch(_Search): _extract(node, "name"), self.network, info={"image": _extract_all(node, "image")}, - ), + ) ) return seq @@ -2606,10 +2651,8 @@ class TrackSearch(_Search): def __init__(self, artist_name, track_title, network): _Search.__init__( - self, - "track", - {"track": track_title, "artist": artist_name}, - network) + self, "track", {"track": track_title, "artist": artist_name}, network + ) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" @@ -2699,7 +2742,8 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and ( - not limit or (len(nodes) < limit)): + not limit or (len(nodes) < limit) + ): nodes.append(node) if page >= total_pages: diff --git a/setup.py b/setup.py index 7ecd757..a937221 100755 --- a/setup.py +++ b/setup.py @@ -10,9 +10,16 @@ setup( long_description_content_type="text/markdown", version="2.3.0.dev0", author="Amr Hassan and Contributors", - install_requires=['six'], - tests_require=['mock', 'pytest', 'coverage', 'pycodestyle', 'pyyaml', - 'pyflakes', 'flaky'], + install_requires=["six"], + tests_require=[ + "mock", + "pytest", + "coverage", + "pycodestyle", + "pyyaml", + "pyflakes", + "flaky", + ], description="A Python interface to Last.fm and Libre.fm", author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", @@ -32,10 +39,10 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - python_requires='>=2.7.10, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + python_requires=">=2.7.10, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", keywords=["Last.fm", "music", "scrobble", "scrobbling"], - packages=find_packages(exclude=('tests*',)), - license="Apache2" + packages=find_packages(exclude=("tests*",)), + license="Apache2", ) # End of file diff --git a/tests/test_album.py b/tests/test_album.py index dd76de8..a2836fd 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -10,10 +10,9 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): - def test_album_tags_are_topitems(self): # Arrange - albums = self.network.get_user('RJ').get_top_albums() + albums = self.network.get_user("RJ").get_top_albums() # Act tags = albums[0].item.get_top_tags(limit=1) @@ -38,7 +37,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): track = lastfm_user.get_recent_tracks(limit=2)[0] # Assert - self.assertTrue(hasattr(track, 'album')) + self.assertTrue(hasattr(track, "album")) def test_album_in_artist_tracks(self): # Arrange @@ -48,7 +47,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] # Assert - self.assertTrue(hasattr(track, 'album')) + self.assertTrue(hasattr(track, "album")) def test_album_wiki_content(self): # Arrange @@ -111,5 +110,5 @@ class TestPyLastAlbum(TestPyLastWithLastFm): self.assert_endswith(image, ".png") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_artist.py b/tests/test_artist.py index a4f2cfd..276e088 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -10,7 +10,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastArtist(TestPyLastWithLastFm): - def test_repr(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -19,8 +18,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): representation = repr(artist) # Assert - self.assertTrue( - representation.startswith("pylast.Artist('Test Artist',")) + self.assertTrue(representation.startswith("pylast.Artist('Test Artist',")) def test_artist_is_hashable(self): # Arrange @@ -136,7 +134,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") -# artist.clear_tags() + # artist.clear_tags() # Act artist.add_tag("testing") @@ -285,8 +283,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): def test_get_userplaycount(self): # Arrange - artist = pylast.Artist("John Lennon", self.network, - username=self.username) + artist = pylast.Artist("John Lennon", self.network, username=self.username) # Act playcount = artist.get_userplaycount() @@ -295,5 +292,5 @@ class TestPyLastArtist(TestPyLastWithLastFm): self.assertGreaterEqual(playcount, 0) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_country.py b/tests/test_country.py index a755db3..1eede36 100755 --- a/tests/test_country.py +++ b/tests/test_country.py @@ -10,7 +10,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastCountry(TestPyLastWithLastFm): - def test_country_is_hashable(self): # Arrange country = self.network.get_country("Italy") @@ -37,5 +36,5 @@ class TestPyLastCountry(TestPyLastWithLastFm): self.assertEqual(url, "https://www.last.fm/place/italy") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_library.py b/tests/test_library.py index a0aceb5..3b3de36 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -10,7 +10,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastLibrary(TestPyLastWithLastFm): - def test_repr(self): # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -57,5 +56,5 @@ class TestPyLastLibrary(TestPyLastWithLastFm): self.assertEqual(library_user, user_to_get) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 1f50743..dba0109 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -22,8 +22,7 @@ class TestPyLastWithLibreFm(PyLastTestCase): password_hash = secrets["password_hash"] # Act - network = pylast.LibreFMNetwork( - password_hash=password_hash, username=username) + network = pylast.LibreFMNetwork(password_hash=password_hash, username=username) artist = network.get_artist("Radiohead") name = artist.get_name() @@ -35,8 +34,7 @@ class TestPyLastWithLibreFm(PyLastTestCase): secrets = load_secrets() username = secrets["username"] password_hash = secrets["password_hash"] - network = pylast.LibreFMNetwork( - password_hash=password_hash, username=username) + network = pylast.LibreFMNetwork(password_hash=password_hash, username=username) # Act representation = repr(network) @@ -45,5 +43,5 @@ class TestPyLastWithLibreFm(PyLastTestCase): self.assert_startswith(representation, "pylast.LibreFMNetwork(") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_network.py b/tests/test_network.py index 4cd1a16..3ce4951 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -11,7 +11,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): - def test_scrobble(self): # Arrange artist = "test artist" @@ -39,7 +38,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Act self.network.update_now_playing( - artist=artist, title=title, album=album, track_number=track_number) + artist=artist, title=title, album=album, track_number=track_number + ) # Assert current_track = lastfm_user.get_now_playing() @@ -89,8 +89,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): def test_geo_get_top_artists(self): # Arrange # Act - artists = self.network.get_geo_top_artists( - country="United Kingdom", limit=1) + artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1) # Assert self.assertEqual(len(artists), 1) @@ -101,7 +100,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Arrange # Act tracks = self.network.get_geo_top_tracks( - country="United Kingdom", location="Manchester", limit=1) + country="United Kingdom", location="Manchester", limit=1 + ) # Assert self.assertEqual(len(tracks), 1) @@ -186,8 +186,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assertEqual(title, name) self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/music/test%2bartist/test%2balbum", url) + self.assertEqual("https://www.last.fm/music/test%2bartist/test%2balbum", url) def test_track_data(self): # Arrange @@ -209,7 +208,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assertIsInstance(playcount, int) self.assertGreater(playcount, 1) self.assertEqual( - "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url) + "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url + ) def test_country_top_artists(self): # Arrange @@ -286,8 +286,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): msg = str(exc) # Assert - self.assertEqual(msg, - "Unauthorized Token - This token has not been issued") + self.assertEqual(msg, "Unauthorized Token - This token has not been issued") def test_proxy(self): # Arrange @@ -297,8 +296,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Act / Assert self.network.enable_proxy(host, port) self.assertTrue(self.network.is_proxy_enabled()) - self.assertEqual(self.network._get_proxy(), - ["https://example.com", 1234]) + self.assertEqual(self.network._get_proxy(), ["https://example.com", 1234]) self.network.disable_proxy() self.assertFalse(self.network.is_proxy_enabled()) @@ -414,5 +412,5 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assertGreater(int(total), 10000) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 2bee953..bb88225 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -16,22 +16,22 @@ def load_secrets(): secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): import yaml # pip install pyyaml + with open(secrets_file, "r") as f: # see example_test_pylast.yaml doc = yaml.load(f) else: doc = {} try: - doc["username"] = os.environ['PYLAST_USERNAME'].strip() - doc["password_hash"] = os.environ['PYLAST_PASSWORD_HASH'].strip() - doc["api_key"] = os.environ['PYLAST_API_KEY'].strip() - doc["api_secret"] = os.environ['PYLAST_API_SECRET'].strip() + doc["username"] = os.environ["PYLAST_USERNAME"].strip() + doc["password_hash"] = os.environ["PYLAST_PASSWORD_HASH"].strip() + doc["api_key"] = os.environ["PYLAST_API_KEY"].strip() + doc["api_secret"] = os.environ["PYLAST_API_SECRET"].strip() except KeyError: pytest.skip("Missing environment variables: PYLAST_USERNAME etc.") return doc class PyLastTestCase(unittest.TestCase): - def assert_startswith(self, str, prefix, start=None, end=None): self.assertTrue(str.startswith(prefix, start, end)) @@ -58,8 +58,11 @@ class TestPyLastWithLastFm(PyLastTestCase): api_secret = self.__class__.secrets["api_secret"] self.network = pylast.LastFMNetwork( - api_key=api_key, api_secret=api_secret, - username=self.username, password_hash=password_hash) + api_key=api_key, + api_secret=api_secret, + username=self.username, + password_hash=password_hash, + ) def helper_is_thing_hashable(self, thing): # Arrange @@ -128,5 +131,5 @@ class TestPyLastWithLastFm(PyLastTestCase): self.assertNotEqual(thing1, thing2) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_tag.py b/tests/test_tag.py index d3980fb..f589b94 100755 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -10,7 +10,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastTag(TestPyLastWithLastFm): - def test_tag_is_hashable(self): # Arrange tag = self.network.get_top_tags(limit=1)[0] @@ -59,5 +58,5 @@ class TestPyLastTag(TestPyLastWithLastFm): self.assertEqual(url, "https://www.last.fm/tag/blues") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_track.py b/tests/test_track.py index 53f84dd..b65d6be 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -10,7 +10,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): - def test_love(self): # Arrange artist = "Test Artist" @@ -48,8 +47,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): artist = "Test Artist" title = "test title" track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) + artist=artist, title=title, network=self.network, username=self.username + ) # Act count = track.get_userplaycount() @@ -62,8 +61,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): artist = "Test Artist" title = "test title" track = pylast.Track( - artist=artist, title=title, - network=self.network, username=self.username) + artist=artist, title=title, network=self.network, username=self.username + ) # Act loved = track.get_userloved() @@ -249,5 +248,5 @@ class TestPyLastTrack(TestPyLastWithLastFm): self.assertIsNone(mbid) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/test_user.py b/tests/test_user.py index 7b0e636..dda91b2 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -11,7 +11,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastUser(TestPyLastWithLastFm): - def test_repr(self): # Arrange user = self.network.get_user("RJ") @@ -107,7 +106,7 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user(self.username) # Act - value = (lastfm_user is None) + value = lastfm_user is None # Assert self.assertFalse(value) @@ -117,7 +116,7 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user(self.username) # Act - value = (lastfm_user is not None) + value = lastfm_user is not None # Assert self.assertTrue(value) @@ -125,7 +124,7 @@ class TestPyLastUser(TestPyLastWithLastFm): def test_now_playing_user_with_no_scrobbles(self): # Arrange # Currently test-account has no scrobbles: - user = self.network.get_user('test-account') + user = self.network.get_user("test-account") # Act current_track = user.get_now_playing() @@ -155,19 +154,20 @@ class TestPyLastUser(TestPyLastWithLastFm): # fails due Last.fm's complaining of hitting the rate limit, even when # limited to one call per second. The ToS allows 5 calls per second. # def test_get_all_scrobbles(self): - # # Arrange - # lastfm_user = self.network.get_user("RJ") - # self.network.enable_rate_limit() # this is going to be slow... + # # Arrange + # lastfm_user = self.network.get_user("RJ") + # self.network.enable_rate_limit() # this is going to be slow... - # # Act - # tracks = lastfm_user.get_recent_tracks(limit=None) + # # Act + # tracks = lastfm_user.get_recent_tracks(limit=None) - # # Assert - # self.assertGreaterEqual(len(tracks), 0) + # # Assert + # self.assertGreaterEqual(len(tracks), 0) def test_pickle(self): # Arrange import pickle + lastfm_user = self.network.get_user(self.username) filename = str(self.unix_timestamp()) + ".pkl" @@ -290,7 +290,7 @@ class TestPyLastUser(TestPyLastWithLastFm): artist.add_tags(tags) # Act - artists = lastfm_user.get_tagged_artists('artisttagola', limit=1) + artists = lastfm_user.get_tagged_artists("artisttagola", limit=1) # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) @@ -303,7 +303,7 @@ class TestPyLastUser(TestPyLastWithLastFm): album.add_tags(tags) # Act - albums = lastfm_user.get_tagged_albums('albumtagola', limit=1) + albums = lastfm_user.get_tagged_albums("albumtagola", limit=1) # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) @@ -316,7 +316,7 @@ class TestPyLastUser(TestPyLastWithLastFm): track.add_tags(tags) # Act - tracks = lastfm_user.get_tagged_tracks('tracktagola', limit=1) + tracks = lastfm_user.get_tagged_tracks("tracktagola", limit=1) # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) @@ -359,15 +359,16 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user("RJ") from datetime import datetime + start = datetime(2011, 7, 21, 15, 10) end = datetime(2011, 7, 21, 15, 15) import calendar + utc_start = calendar.timegm(start.utctimetuple()) utc_end = calendar.timegm(end.utctimetuple()) # Act - tracks = lastfm_user.get_recent_tracks(time_from=utc_start, - time_to=utc_end) + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end) # Assert self.assertEqual(len(tracks), 1) @@ -430,5 +431,5 @@ class TestPyLastUser(TestPyLastWithLastFm): self.assertIsInstance(track.network, pylast.LastFMNetwork) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(failfast=True) diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 511ecd1..5aab7c1 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -7,23 +7,24 @@ import pylast def mock_network(): - return mock.Mock( - _get_ws_auth=mock.Mock(return_value=("", "", "")) - ) + return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", ""))) -@pytest.mark.parametrize('artist', [ - u'\xe9lafdasfdsafdsa', u'ééééééé', - pylast.Artist(u'B\xe9l', mock_network()), - 'fdasfdsafsaf not unicode', -]) +@pytest.mark.parametrize( + "artist", + [ + u"\xe9lafdasfdsafdsa", + u"ééééééé", + pylast.Artist(u"B\xe9l", mock_network()), + "fdasfdsafsaf not unicode", + ], +) def test_get_cache_key(artist): - request = pylast._Request(mock_network(), 'some_method', - params={'artist': artist}) + request = pylast._Request(mock_network(), "some_method", params={"artist": artist}) request._get_cache_key() -@pytest.mark.parametrize('obj', [pylast.Artist(u'B\xe9l', mock_network())]) +@pytest.mark.parametrize("obj", [pylast.Artist(u"B\xe9l", mock_network())]) def test_cast_and_hash(obj): assert type(six.text_type(obj)) is six.text_type assert isinstance(hash(obj), int) From ba946170da712ab92adadc3490283afb1ea30d53 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 6 Jun 2018 10:39:02 +0300 Subject: [PATCH 442/768] Add 'max_line_length = 88' for flake8 --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index cc065b0..9adb3ce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,9 @@ [bdist_wheel] universal = 1 +[flake8] +max_line_length = 88 + [metadata] license_file = COPYING From 9194232ecd13c079e9cc94dc535448d8ca43e0b9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 6 Jun 2018 11:23:39 +0300 Subject: [PATCH 443/768] Sort tests_require --- setup.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index a937221..910f5c5 100755 --- a/setup.py +++ b/setup.py @@ -12,13 +12,13 @@ setup( author="Amr Hassan and Contributors", install_requires=["six"], tests_require=[ - "mock", - "pytest", "coverage", - "pycodestyle", - "pyyaml", - "pyflakes", "flaky", + "mock", + "pycodestyle", + "pyflakes", + "pytest", + "pyyaml", ], description="A Python interface to Last.fm and Libre.fm", author_email="amr.hassan@gmail.com", From 65030026925125da6d53edd61d78d810db865ed1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 6 Jun 2018 11:26:00 +0300 Subject: [PATCH 444/768] Fix commented-out formatting post-Black --- tests/test_artist.py | 2 +- tests/test_user.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 276e088..914fa6a 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -134,7 +134,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") - # artist.clear_tags() + # artist.clear_tags() # Act artist.add_tag("testing") diff --git a/tests/test_user.py b/tests/test_user.py index dda91b2..835d131 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -154,15 +154,15 @@ class TestPyLastUser(TestPyLastWithLastFm): # fails due Last.fm's complaining of hitting the rate limit, even when # limited to one call per second. The ToS allows 5 calls per second. # def test_get_all_scrobbles(self): - # # Arrange - # lastfm_user = self.network.get_user("RJ") - # self.network.enable_rate_limit() # this is going to be slow... - - # # Act - # tracks = lastfm_user.get_recent_tracks(limit=None) - - # # Assert - # self.assertGreaterEqual(len(tracks), 0) + # # Arrange + # lastfm_user = self.network.get_user("RJ") + # self.network.enable_rate_limit() # this is going to be slow... + # + # # Act + # tracks = lastfm_user.get_recent_tracks(limit=None) + # + # # Assert + # self.assertGreaterEqual(len(tracks), 0) def test_pickle(self): # Arrange From 5a51d52e0ad0fea21ca8ae74fec9d8908a88ec6e Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 6 Jun 2018 11:37:40 +0300 Subject: [PATCH 445/768] Add Black badge --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f4af28b..157e69f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). @@ -48,7 +49,7 @@ Features * Python 3-friendly (Starting from 0.5). -Getting Started +Getting started --------------- Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: From 018675aeddfd1f2e7c08464ea04fb4e9831e280a Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Jun 2018 15:28:25 +0300 Subject: [PATCH 446/768] Run pyupgrade --- pylast/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 9a14011..a2f5a20 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -258,7 +258,7 @@ class _Network(object): return self.domain_names[domain_language] def _get_url(self, domain, url_type): - return "https://%s/%s" % ( + return "https://{}/{}".format( self._get_language_domain(domain), self.urls[url_type], ) @@ -1458,7 +1458,7 @@ class _Opus(_BaseObject, _Taggable): self.info = info def __repr__(self): - return "pylast.%s(%s, %s, %s)" % ( + return "pylast.{}({}, {}, {})".format( self.ws_prefix.title(), repr(self.artist.name), repr(self.title), @@ -1639,7 +1639,7 @@ class Artist(_BaseObject, _Taggable): self.info = info def __repr__(self): - return "pylast.Artist(%s, %s)" % (repr(self.get_name()), repr(self.network)) + return "pylast.Artist({}, {})".format(repr(self.get_name()), repr(self.network)) def __unicode__(self): return six.text_type(self.get_name()) @@ -1840,7 +1840,7 @@ class Country(_BaseObject): self.name = name def __repr__(self): - return "pylast.Country(%s, %s)" % (repr(self.name), repr(self.network)) + return "pylast.Country({}, {})".format(repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -1918,7 +1918,7 @@ class Library(_BaseObject): self.user = User(user, self.network) def __repr__(self): - return "pylast.Library(%s, %s)" % (repr(self.user), repr(self.network)) + return "pylast.Library({}, {})".format(repr(self.user), repr(self.network)) @_string_output def __str__(self): @@ -1965,7 +1965,7 @@ class Tag(_BaseObject, _Chartable): self.name = name def __repr__(self): - return "pylast.Tag(%s, %s)" % (repr(self.name), repr(self.network)) + return "pylast.Tag({}, {})".format(repr(self.name), repr(self.network)) @_string_output def __str__(self): @@ -2174,7 +2174,7 @@ class User(_BaseObject, _Chartable): self.name = user_name def __repr__(self): - return "pylast.User(%s, %s)" % (repr(self.name), repr(self.network)) + return "pylast.User({}, {})".format(repr(self.name), repr(self.network)) @_string_output def __str__(self): From d65e2bdb3e8803c6ff7c18294e1681640e5a87ab Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Jun 2018 15:28:49 +0300 Subject: [PATCH 447/768] Run Black --- pylast/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index a2f5a20..bf63c29 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -259,8 +259,7 @@ class _Network(object): def _get_url(self, domain, url_type): return "https://{}/{}".format( - self._get_language_domain(domain), - self.urls[url_type], + self._get_language_domain(domain), self.urls[url_type] ) def _get_ws_auth(self): From 4c89c7bf5a82285458297f0e988461d3b0b14833 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Jun 2018 17:37:26 +0300 Subject: [PATCH 448/768] Release 2.3.0 --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index bf63c29..fcc3986 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import tempfile import time import xml.dom -__version__ = "2.3.0.dev0" +__version__ = "2.3.0" __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = ( "Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, " "2017 Mice Pápai" diff --git a/setup.py b/setup.py index 910f5c5..a31861d 100755 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", - version="2.3.0.dev0", + version="2.3.0", author="Amr Hassan and Contributors", install_requires=["six"], tests_require=[ From 5d085ba9dc1dfda94a41f5264bcabf5f95ac3a68 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 8 Jun 2018 17:41:40 +0300 Subject: [PATCH 449/768] Start new release cycle --- pylast/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fcc3986..7c989dc 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,7 +32,7 @@ import tempfile import time import xml.dom -__version__ = "2.3.0" +__version__ = "2.4.0.dev0" __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = ( "Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, " "2017 Mice Pápai" diff --git a/setup.py b/setup.py index a31861d..fe1c40c 100755 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", - version="2.3.0", + version="2.4.0.dev0", author="Amr Hassan and Contributors", install_requires=["six"], tests_require=[ From 35b97356d1aaf2dc0f07348ec3499be49237f194 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 10 Apr 2018 14:39:18 +0300 Subject: [PATCH 450/768] Single-source the package version --- pylast/__init__.py | 5 +++-- setup.py | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 7c989dc..0b9755b 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -32,14 +32,15 @@ import tempfile import time import xml.dom -__version__ = "2.4.0.dev0" +from . import version + __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = ( "Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, " "2017 Mice Pápai" ) __license__ = "apache2" __email__ = "amr.hassan@gmail.com" - +__version__ = version.__version__ if sys.version_info.major == 2: import htmlentitydefs diff --git a/setup.py b/setup.py index fe1c40c..1c6d5cb 100755 --- a/setup.py +++ b/setup.py @@ -4,11 +4,16 @@ from setuptools import find_packages, setup with open("README.md") as f: long_description = f.read() +version_dict = {} +with open("pylast/version.py") as f: + exec(f.read(), version_dict) + version = version_dict['__version__'] + setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", - version="2.4.0.dev0", + version=version, author="Amr Hassan and Contributors", install_requires=["six"], tests_require=[ From c6b080d578cf8b31700a8b0965bdc348291a75ca Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Jun 2018 09:40:15 +0300 Subject: [PATCH 451/768] Single-source the package version --- pylast/version.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pylast/version.py diff --git a/pylast/version.py b/pylast/version.py new file mode 100644 index 0000000..2a59a87 --- /dev/null +++ b/pylast/version.py @@ -0,0 +1,2 @@ +# Master version for pylast +__version__ = "2.4.0.dev0" From efd123004a955018172dfd4f52281bee81f53ea5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Jun 2018 09:43:41 +0300 Subject: [PATCH 452/768] Update release docs --- RELEASING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 5e22fb7..45da843 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,10 +1,10 @@ # Release Checklist * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Remove `.dev0` suffix from version in `pylast/__init__.py` and `setup.py`: +* [ ] Remove `.dev0` suffix from the version: ```bash git checkout master -edit pylast/__init__.py setup.py +edit pylast/version.py ``` * [ ] Commit and tag with the version number: ```bash @@ -28,10 +28,10 @@ git push --tags * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new * Tag: Pick existing tag "2.1.0" * Title: "Release 2.1.0" -* [ ] Increment version and append `.dev0` in `pylast/__init__.py` and `setup.py`: +* [ ] Increment version and append `.dev0`: ```bash git checkout master -edit pylast/__init__.py setup.py +edit pylast/version.py ``` * [ ] Commit and push: ```bash From d16423c3435dfb631bd286726606b78576ce24a8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Jun 2018 09:44:39 +0300 Subject: [PATCH 453/768] CI: Also show Black diff on failure --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 89fa5d0..fb2ccdd 100644 --- a/tox.ini +++ b/tox.ini @@ -44,4 +44,4 @@ deps = black commands = {[testenv:lint]commands} - black --check . + black --check --diff . From ca11de0a3a5a94ed8d1f6e661bee95ce41d564d4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Jun 2018 09:44:59 +0300 Subject: [PATCH 454/768] Black fix --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1c6d5cb..4cb8aa8 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open("README.md") as f: version_dict = {} with open("pylast/version.py") as f: exec(f.read(), version_dict) - version = version_dict['__version__'] + version = version_dict["__version__"] setup( name="pylast", From 907891bcb108a1e89f04c04a2daefa6743d244a7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Jun 2018 09:50:08 +0300 Subject: [PATCH 455/768] Include version file in manifest --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index c778b80..a82ada5 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include pylast/__init__.py +include pylast/*.py include setup.py include README.md include COPYING From 691e0a9ab17f16fdb25c5e73c86e3484e958d5cf Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 23 Jun 2018 22:57:08 +0300 Subject: [PATCH 456/768] pylast >= 3.0.0 will likely only support Python 3.4+ See https://github.com/pylast/pylast/issues/265. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 157e69f..89334f7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Or from requirements.txt: Note: +* pylast >= 3.0.0 will likely only support Python 3.4+ ([#265](https://github.com/pylast/pylast/issues/265)) * pyLast >= 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. * pyLast >= 2.0.0 < 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. * pyLast >= 1.7.0 < 2.0.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. From 7665fd09d7b573a7ba420206b8ddc8787b4fc96f Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Jun 2018 16:30:29 +0300 Subject: [PATCH 457/768] Revert "Add workaround for build failures on Python 3.7 (https://github.com/yaml/pyyaml/issues/126)" This reverts commit f2ec5bc57ef06c0f8db9a92f2ebb628460e358aa. --- tox.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index fb2ccdd..97ae573 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,7 @@ setenv = PYLAST_API_KEY={env:PYLAST_API_KEY:} PYLAST_API_SECRET={env:PYLAST_API_SECRET:} deps = - # Workaround for yaml/pyyaml#126 - py27,py36,py35,py34,pypy,pypy3,py36dev: pyyaml - py37dev: git+https://github.com/yaml/pyyaml@master#egg=pyyaml + pyyaml pytest mock ipdb From 905d06ef5d942e30d6f5587085f5461e97e8ae32 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Jul 2018 15:35:38 +0300 Subject: [PATCH 458/768] Upgrade Python syntax with pyupgrade --- pylast/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0b9755b..d5622c2 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -1018,11 +1018,11 @@ class SessionKeyGenerator(object): token = self._get_web_auth_token() - url = "%(homepage)s/api/auth/?api_key=%(api)s&token=%(token)s" % { - "homepage": self.network.homepage, - "api": self.network.api_key, - "token": token, - } + url = "{homepage}/api/auth/?api_key={api}&token={token}".format( + homepage=self.network.homepage, + api=self.network.api_key, + token=token, + ) self.web_auth_tokens[url] = token From c1e70f798c4ff3ceb5c5aff31e9d0c448eff8665 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Jul 2018 15:36:59 +0300 Subject: [PATCH 459/768] UserWarning when importing on Python <3 https://python3statement.org/practicalities/#runtime-warning-on-master --- pylast/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index d5622c2..3061d3d 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -30,6 +30,7 @@ import ssl import sys import tempfile import time +import warnings import xml.dom from . import version @@ -42,6 +43,12 @@ __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = version.__version__ +if sys.version_info < (3,): + warnings.warn('You are using pylast with Python 2. ' + 'Pylast will soon be Python 3 only. ' + 'More info: https://github.com/pylast/pylast/issues/265', + UserWarning) + if sys.version_info.major == 2: import htmlentitydefs from httplib import HTTPSConnection From be52e1793ae4b451e44a3967959acc6c07c53023 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Jul 2018 16:02:00 +0300 Subject: [PATCH 460/768] Black fix --- pylast/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 3061d3d..b2ffa37 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -44,10 +44,12 @@ __email__ = "amr.hassan@gmail.com" __version__ = version.__version__ if sys.version_info < (3,): - warnings.warn('You are using pylast with Python 2. ' - 'Pylast will soon be Python 3 only. ' - 'More info: https://github.com/pylast/pylast/issues/265', - UserWarning) + warnings.warn( + "You are using pylast with Python 2. " + "Pylast will soon be Python 3 only. " + "More info: https://github.com/pylast/pylast/issues/265", + UserWarning, + ) if sys.version_info.major == 2: import htmlentitydefs @@ -1026,9 +1028,7 @@ class SessionKeyGenerator(object): token = self._get_web_auth_token() url = "{homepage}/api/auth/?api_key={api}&token={token}".format( - homepage=self.network.homepage, - api=self.network.api_key, - token=token, + homepage=self.network.homepage, api=self.network.api_key, token=token ) self.web_auth_tokens[url] = token From 518890423a28aec5b74e6fb6baddb24fb93be63f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 29 Jul 2018 12:57:34 +0300 Subject: [PATCH 461/768] Test on official Python 3.7 --- .travis.yml | 4 ++++ tox.ini | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d1f1e8c..6d94629 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,10 @@ matrix: env: TOXENV=py27 - python: 3.6 env: TOXENV=py3lint + - python: 3.7 + env: TOXENV=py37 + dist: xenial + sudo: true - python: 3.6 env: TOXENV=py36 - python: 3.5 diff --git a/tox.ini b/tox.ini index 97ae573..fd2f908 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py36, py35, py34, pypy, pypy3, py36dev, py37dev +envlist = py27, py37, py36, py35, py34, pypy, pypy3, py36dev, py37dev recreate = False [testenv] From bf68d17ca214e438fab182f5e03e1d7ae2c52d63 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 8 Aug 2018 11:03:59 +0300 Subject: [PATCH 462/768] Release 2.4.0 --- pylast/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/version.py b/pylast/version.py index 2a59a87..58f065f 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "2.4.0.dev0" +__version__ = "2.4.0" From 245dd47a413e3315b2115558e1c83f4dc9095b18 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 8 Aug 2018 11:23:04 +0300 Subject: [PATCH 463/768] Start new release cycle --- RELEASING.md | 4 ++-- pylast/version.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 45da843..4416ee0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,7 +8,7 @@ edit pylast/version.py ``` * [ ] Commit and tag with the version number: ```bash -git add pylast/__init__.py setup.py +git add pylast/version.py git commit -m "Release 2.1.0" git tag -a 2.1.0 -m "Release 2.1.0" ``` @@ -35,7 +35,7 @@ edit pylast/version.py ``` * [ ] Commit and push: ```bash -git add pylast/__init__.py setup.py +git add pylast/version.py git commit -m "Start new release cycle" git push ``` diff --git a/pylast/version.py b/pylast/version.py index 58f065f..ddb9d9b 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "2.4.0" +__version__ = "2.5.0.dev0" From c269f3567ca4d2a736f7af58654050b1fbf17fd8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 9 Aug 2018 11:36:21 +0300 Subject: [PATCH 464/768] Add a changelog [CI skip] --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f801aff --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- This changelog file ([#273]) + + +## [2.4.0] - 2018-08-08 +### Deprecated + +* Support for Python 2.7 ([#265]) + + +[Unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v2.4.0...HEAD +[2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 +[#265]: https://github.com/pylast/pylast/issues/265 +[#273]: https://github.com/pylast/pylast/issues/273 From d1d17314b2280b20fc4a825e68987b77ac989c53 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 14 Aug 2018 16:14:37 +0300 Subject: [PATCH 465/768] Remove deprecated COVER_X constants --- pylast/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index b2ffa37..19d6ad8 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -98,12 +98,11 @@ DOMAIN_RUSSIAN = 9 DOMAIN_JAPANESE = 10 DOMAIN_CHINESE = 11 -# COVER_X is deprecated since 2.1.0 and will be removed in a future version -SIZE_SMALL = COVER_SMALL = 0 -SIZE_MEDIUM = COVER_MEDIUM = 1 -SIZE_LARGE = COVER_LARGE = 2 -SIZE_EXTRA_LARGE = COVER_EXTRA_LARGE = 3 -SIZE_MEGA = COVER_MEGA = 4 +SIZE_SMALL = 0 +SIZE_MEDIUM = 1 +SIZE_LARGE = 2 +SIZE_EXTRA_LARGE = 3 +SIZE_MEGA = 4 IMAGES_ORDER_POPULARITY = "popularity" IMAGES_ORDER_DATE = "dateadded" From eeb65d97240e61c780f8a4b9fc1ee072e6a9b1da Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 14 Aug 2018 16:37:39 +0300 Subject: [PATCH 466/768] Update changelog and version --- CHANGELOG.md | 7 ++++++- pylast/version.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f801aff..2cd65e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - This changelog file ([#273]) +### Removed + +- Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` + and `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282]) ## [2.4.0] - 2018-08-08 ### Deprecated @@ -16,7 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) -[Unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v2.4.0...HEAD +[Unreleased]: https://github.com/pylast/pylast/compare/v2.4.0...HEAD [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 +[#282]: https://github.com/pylast/pylast/pull/282 diff --git a/pylast/version.py b/pylast/version.py index ddb9d9b..88717cc 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "2.5.0.dev0" +__version__ = "3.0.0.dev0" From 48f1d72c45a3bb0cb8aa94709831230f781ab39f Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 19 Nov 2018 22:41:41 +0200 Subject: [PATCH 467/768] Use VM-based infrastructure More info: https://blog.travis-ci.com/2018-11-19-required-linux-infrastructure-migration --- .travis.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6d94629..d04458d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,8 +44,6 @@ matrix: - env: TOXENV=pypy3 fast_finish: true -sudo: false - install: - travis_retry pip install tox - travis_retry pip install coverage From f9a130f1ba3409cbfd1624882ea5b52c88e0664c Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 26 Nov 2018 23:18:17 +0200 Subject: [PATCH 468/768] Test on 3.8-dev, remove outdated 3.6-dev and 3.7-dev --- .travis.yml | 6 ++---- tox.ini | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index d04458d..f63448d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,10 +35,8 @@ matrix: env: TOXENV=pypy3 - python: pypy env: TOXENV=pypy - - python: 3.6-dev - env: TOXENV=py36dev - - python: 3.7-dev - env: TOXENV=py37dev + - python: 3.8-dev + env: TOXENV=py38dev allow_failures: - env: TOXENV=pypy - env: TOXENV=pypy3 diff --git a/tox.ini b/tox.ini index fd2f908..22cc282 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py37, py36, py35, py34, pypy, pypy3, py36dev, py37dev +envlist = py27, py37, py36, py35, py34, pypy, pypy3, py38dev recreate = False [testenv] From 51821e4a9abadca42922377cceecf49a6291070f Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 26 Nov 2018 23:25:58 +0200 Subject: [PATCH 469/768] Xenial required for 3.7+ --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index f63448d..806ff26 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,6 +37,7 @@ matrix: env: TOXENV=pypy - python: 3.8-dev env: TOXENV=py38dev + dist: xenial allow_failures: - env: TOXENV=pypy - env: TOXENV=pypy3 From 07fac0628c0fed1d99d2217ede407083dd593e65 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 14 Aug 2018 16:26:15 +0300 Subject: [PATCH 470/768] Drop support for legacy Python 2 --- .travis.yml | 9 +----- CHANGELOG.md | 3 ++ README.md | 12 ++++---- clonedigger.sh | 4 --- pylast/__init__.py | 67 +++++++++++++++++++++++-------------------- pylast/version.py | 2 +- setup.py | 42 ++++++++++++++++++++++----- tests/test_user.py | 2 +- tests/unicode_test.py | 11 ++++--- tox.ini | 18 ++---------- 10 files changed, 90 insertions(+), 80 deletions(-) delete mode 100755 clonedigger.sh diff --git a/.travis.yml b/.travis.yml index 806ff26..1ab965b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,12 +15,8 @@ env: matrix: include: - - python: 2.7 - env: TOXENV=py2lint - - python: 2.7 - env: TOXENV=py27 - python: 3.6 - env: TOXENV=py3lint + env: TOXENV=lint - python: 3.7 env: TOXENV=py37 dist: xenial @@ -33,13 +29,10 @@ matrix: env: TOXENV=py34 - python: pypy3 env: TOXENV=pypy3 - - python: pypy - env: TOXENV=pypy - python: 3.8-dev env: TOXENV=py38dev dist: xenial allow_failures: - - env: TOXENV=pypy - env: TOXENV=pypy3 fast_finish: true diff --git a/CHANGELOG.md b/CHANGELOG.md index f801aff..3c8d300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - This changelog file ([#273]) +### Removed + +* Support for Python 2.7 ([#265]) ## [2.4.0] - 2018-08-08 ### Deprecated diff --git a/README.md b/README.md index 89334f7..13d8b64 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,12 @@ Or from requirements.txt: Note: -* pylast >= 3.0.0 will likely only support Python 3.4+ ([#265](https://github.com/pylast/pylast/issues/265)) -* pyLast >= 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. -* pyLast >= 2.0.0 < 2.2.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. -* pyLast >= 1.7.0 < 2.0.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. -* pyLast >= 1.0.0 < 1.7.0 supports Python 2.7, 3.3, 3.4. -* pyLast >= 0.5 < 1.0.0 supports Python 2, 3. +* pylast 3.0.0+ supports Python 3.4+ ([#265](https://github.com/pylast/pylast/issues/265)) +* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. +* pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. +* pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. +* pyLast 1.0.0 - 1.6.0 supports Python 2.7, 3.3, 3.4. +* pyLast 0.5 supports Python 2, 3. * pyLast < 0.5 supports Python 2. Features diff --git a/clonedigger.sh b/clonedigger.sh deleted file mode 100755 index 96dc493..0000000 --- a/clonedigger.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -clonedigger pylast -grep -E "Clones detected|lines are duplicates" output.html -exit 0 diff --git a/pylast/__init__.py b/pylast/__init__.py index b2ffa37..1e988bf 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -25,13 +25,13 @@ import collections import hashlib import logging import shelve -import six import ssl import sys import tempfile import time -import warnings import xml.dom +import html.entities +from http.client import HTTPSConnection from . import version @@ -44,24 +44,33 @@ __email__ = "amr.hassan@gmail.com" __version__ = version.__version__ if sys.version_info < (3,): - warnings.warn( - "You are using pylast with Python 2. " - "Pylast will soon be Python 3 only. " - "More info: https://github.com/pylast/pylast/issues/265", - UserWarning, + raise ImportError( + """pylast 3.0 and above are no longer compatible with Python 2. + +This is pylast {} and you are using Python {}. +Make sure you have pip >= 9.0 and setuptools >= 24.2 and retry: + + $ pip install --upgrade pip setuptools + +Other choices: + +- Upgrade to Python 3. + +- Install an older version of pylast: + +$ pip install 'pylast<3.0' + +For more information: + +https://github.com/pylast/pylast/issues/265 +""".format( + version, ".".join([str(v) for v in sys.version_info[:3]]) + ) ) - -if sys.version_info.major == 2: - import htmlentitydefs - from httplib import HTTPSConnection - from urllib import quote_plus as url_quote_plus else: - import html.entities as htmlentitydefs - from http.client import HTTPSConnection + # Keep importable on Python 2 for a while to show ImportError from urllib.parse import quote_plus as url_quote_plus - unichr = chr - STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 @@ -123,7 +132,7 @@ SCROBBLE_MODE_SKIPPED = "S" # Delay time in seconds from section 4.4 of https://www.last.fm/api/tos DELAY_TIME = 0.2 -# Python >3.4 and >2.7.9 has sane defaults +# Python >3.4 has sane defaults SSL_CONTEXT = ssl.create_default_context() logger = logging.getLogger(__name__) @@ -1118,10 +1127,10 @@ class _BaseObject(object): def __hash__(self): # Convert any ints (or whatever) into strings - values = map(six.text_type, self._get_params().values()) + values = map(str, self._get_params().values()) return hash(self.network) + hash( - six.text_type(type(self)) + str(type(self)) + "".join(list(self._get_params().keys()) + list(values)).lower() ) @@ -1649,7 +1658,7 @@ class Artist(_BaseObject, _Taggable): return "pylast.Artist({}, {})".format(repr(self.get_name()), repr(self.network)) def __unicode__(self): - return six.text_type(self.get_name()) + return str(self.get_name()) @_string_output def __str__(self): @@ -2690,22 +2699,18 @@ def md5(text): def _unicode(text): - if isinstance(text, six.binary_type): - return six.text_type(text, "utf-8") - elif isinstance(text, six.text_type): + if isinstance(text, bytes): + return str(text, "utf-8") + elif isinstance(text, str): return text else: - return six.text_type(text) + return str(text) def _string(string): - """For Python2 routines that can only process str type.""" if isinstance(string, str): return string - casted = six.text_type(string) - if sys.version_info.major == 2: - casted = casted.encode("utf-8") - return casted + return str(string) def cleanup_nodes(doc): @@ -2864,9 +2869,9 @@ def _unescape_htmlentity(string): # string = _unicode(string) - mapping = htmlentitydefs.name2codepoint + mapping = html.entities.name2codepoint for key in mapping: - string = string.replace("&%s;" % key, unichr(mapping[key])) + string = string.replace("&%s;" % key, chr(mapping[key])) return string diff --git a/pylast/version.py b/pylast/version.py index ddb9d9b..88717cc 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "2.5.0.dev0" +__version__ = "3.0.0.dev0" diff --git a/setup.py b/setup.py index 4cb8aa8..78832c1 100755 --- a/setup.py +++ b/setup.py @@ -1,21 +1,50 @@ #!/usr/bin/env python -from setuptools import find_packages, setup +from __future__ import print_function +import sys -with open("README.md") as f: - long_description = f.read() +from setuptools import find_packages, setup version_dict = {} with open("pylast/version.py") as f: exec(f.read(), version_dict) version = version_dict["__version__"] + +if sys.version_info < (3, 4): + error = """pylast 3.0 and above are no longer compatible with Python 2. + +This is pylast {} and you are using Python {}. +Make sure you have pip >= 9.0 and setuptools >= 24.2 and retry: + + $ pip install --upgrade pip setuptools + +Other choices: + +- Upgrade to Python 3. + +- Install an older version of pylast: + +$ pip install 'pylast<3.0' + +For more information: + +https://github.com/pylast/pylast/issues/265 +""".format( + version, ".".join([str(v) for v in sys.version_info[:3]]) + ) + print(error, file=sys.stderr) + sys.exit(1) + +with open("README.md") as f: + long_description = f.read() + + setup( name="pylast", long_description=long_description, long_description_content_type="text/markdown", version=version, author="Amr Hassan and Contributors", - install_requires=["six"], tests_require=[ "coverage", "flaky", @@ -34,17 +63,16 @@ setup( "Topic :: Internet", "Topic :: Multimedia :: Sound/Audio", "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - python_requires=">=2.7.10, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", + python_requires=">=3.4", keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=("tests*",)), license="Apache2", diff --git a/tests/test_user.py b/tests/test_user.py index 835d131..1e7c76d 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -67,7 +67,7 @@ class TestPyLastUser(TestPyLastWithLastFm): else: # Old way # Just check date because of timezones - self.assertIn(u"2002-11-20 ", registered) + self.assertIn("2002-11-20 ", registered) def test_get_user_unixtime_registration(self): # Arrange diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 5aab7c1..062f37f 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import mock import pytest -import six import pylast @@ -13,9 +12,9 @@ def mock_network(): @pytest.mark.parametrize( "artist", [ - u"\xe9lafdasfdsafdsa", - u"ééééééé", - pylast.Artist(u"B\xe9l", mock_network()), + "\xe9lafdasfdsafdsa", + "ééééééé", + pylast.Artist("B\xe9l", mock_network()), "fdasfdsafsaf not unicode", ], ) @@ -24,7 +23,7 @@ def test_get_cache_key(artist): request._get_cache_key() -@pytest.mark.parametrize("obj", [pylast.Artist(u"B\xe9l", mock_network())]) +@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())]) def test_cast_and_hash(obj): - assert type(six.text_type(obj)) is six.text_type + assert type(str(obj)) is str assert isinstance(hash(obj), int) diff --git a/tox.ini b/tox.ini index 22cc282..fe07234 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py37, py36, py35, py34, pypy, pypy3, py38dev +envlist = py37, py36, py35, py34, pypy3, py38dev recreate = False [testenv] @@ -25,21 +25,7 @@ commands = {posargs} deps = flake8 pep8-naming -commands = - flake8 . - -[testenv:py2lint] -deps = - {[testenv:lint]deps} - clonedigger -commands = - {[testenv:lint]commands} - ./clonedigger.sh - -[testenv:py3lint] -deps = - {[testenv:lint]deps} black commands = - {[testenv:lint]commands} + flake8 . black --check --diff . From 742df8b674604959524b90fa7236501c9fe4af1a Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 26 Nov 2018 22:50:06 +0200 Subject: [PATCH 471/768] Drop support for Python 3.4 (EOL 2019-03-16) --- .travis.yml | 2 -- README.md | 4 ++-- setup.py | 5 ++--- tox.ini | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1ab965b..5b8e058 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,8 +25,6 @@ matrix: env: TOXENV=py36 - python: 3.5 env: TOXENV=py35 - - python: 3.4 - env: TOXENV=py34 - python: pypy3 env: TOXENV=pypy3 - python: 3.8-dev diff --git a/README.md b/README.md index 13d8b64..9a43eda 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Installation Install via pip: pip install pylast - + Install latest development version: pip install -U git+https://github.com/pylast/pylast.git @@ -29,7 +29,7 @@ Or from requirements.txt: Note: -* pylast 3.0.0+ supports Python 3.4+ ([#265](https://github.com/pylast/pylast/issues/265)) +* pylast 3.0.0+ supports Python 3.5+ ([#265](https://github.com/pylast/pylast/issues/265)) * pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. * pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. * pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. diff --git a/setup.py b/setup.py index 78832c1..496bb21 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ with open("pylast/version.py") as f: version = version_dict["__version__"] -if sys.version_info < (3, 4): +if sys.version_info < (3, 5): error = """pylast 3.0 and above are no longer compatible with Python 2. This is pylast {} and you are using Python {}. @@ -64,7 +64,6 @@ setup( "Topic :: Multimedia :: Sound/Audio", "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", @@ -72,7 +71,7 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - python_requires=">=3.4", + python_requires=">=3.5", keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=("tests*",)), license="Apache2", diff --git a/tox.ini b/tox.ini index fe07234..a84fb2a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py36, py35, py34, pypy3, py38dev +envlist = py37, py36, py35, pypy3, py38dev recreate = False [testenv] From ee879aa042381f1562d335d73bf7cb32ede3d1f1 Mon Sep 17 00:00:00 2001 From: hugovk Date: Mon, 26 Nov 2018 23:10:38 +0200 Subject: [PATCH 472/768] Upgrade Python syntax with pyupgrade --py3-plus --- pylast/__init__.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0a32950..fd367ef 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -138,7 +138,7 @@ logger = logging.getLogger(__name__) logging.getLogger(__name__).addHandler(logging.NullHandler()) -class _Network(object): +class _Network: """ A music social network website such as Last.fm or one with a Last.fm-compatible API. @@ -795,7 +795,7 @@ class LibreFMNetwork(_Network): ) -class _ShelfCacheBackend(object): +class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None): @@ -816,7 +816,7 @@ class _ShelfCacheBackend(object): self.shelf[key] = xml_string -class _Request(object): +class _Request: """Representing an abstract web service operation.""" def __init__(self, network, method_name, params=None): @@ -981,7 +981,7 @@ class _Request(object): raise WSError(self.network, status, details) -class SessionKeyGenerator(object): +class SessionKeyGenerator: """Methods of generating a session key: 1) Web Authentication: a. network = get_*_network(API_KEY, API_SECRET) @@ -1104,7 +1104,7 @@ def _string_output(func): return r -class _BaseObject(object): +class _BaseObject: """An abstract webservices object.""" network = None @@ -1193,7 +1193,7 @@ class _BaseObject(object): return _extract(node, section) -class _Chartable(object): +class _Chartable: """Common functions for classes with charts.""" def __init__(self, ws_prefix): @@ -1264,7 +1264,7 @@ class _Chartable(object): return seq -class _Taggable(object): +class _Taggable: """Common functions for classes with tags.""" def __init__(self, ws_prefix): @@ -1593,7 +1593,7 @@ class Album(_Opus): __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None): - super(Album, self).__init__(artist, title, network, "album", username, info) + super().__init__(artist, title, network, "album", username, info) def get_tracks(self): """Returns the list of Tracks on this album.""" @@ -2062,7 +2062,7 @@ class Track(_Opus): __hash__ = _Opus.__hash__ def __init__(self, artist, title, network, username=None, info=None): - super(Track, self).__init__(artist, title, network, "track", username, info) + super().__init__(artist, title, network, "track", username, info) def get_correction(self): """Returns the corrected track name.""" From 4626ff9670ef08f9ed04d4db72804659ba45dce6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 30 Dec 2018 13:10:07 +0200 Subject: [PATCH 473/768] Reorder setup params --- setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 496bb21..a7e7e3d 100755 --- a/setup.py +++ b/setup.py @@ -41,10 +41,13 @@ with open("README.md") as f: setup( name="pylast", + description="A Python interface to Last.fm and Libre.fm", long_description=long_description, long_description_content_type="text/markdown", version=version, author="Amr Hassan and Contributors", + author_email="amr.hassan@gmail.com", + url="https://github.com/pylast/pylast", tests_require=[ "coverage", "flaky", @@ -54,9 +57,7 @@ setup( "pytest", "pyyaml", ], - description="A Python interface to Last.fm and Libre.fm", - author_email="amr.hassan@gmail.com", - url="https://github.com/pylast/pylast", + python_requires=">=3.5", classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", @@ -71,7 +72,6 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - python_requires=">=3.5", keywords=["Last.fm", "music", "scrobble", "scrobbling"], packages=find_packages(exclude=("tests*",)), license="Apache2", From c9fa096ef2634e725ffdf1038c63a22d63780d8e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 30 Dec 2018 13:10:40 +0200 Subject: [PATCH 474/768] No need for .git extension --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a43eda..457345c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Install via pip: Install latest development version: - pip install -U git+https://github.com/pylast/pylast.git + pip install -U git+https://github.com/pylast/pylast Or from requirements.txt: From eb53483f6fb799039fec209fae65455403862f6f Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 1 Jan 2019 17:13:16 +0200 Subject: [PATCH 475/768] Release 3.0.0 --- pylast/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylast/version.py b/pylast/version.py index 88717cc..50b9eb9 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "3.0.0.dev0" +__version__ = "3.0.0" From f67afa0d582565e5622d5f47a57b5af138a6f428 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 1 Jan 2019 18:01:42 +0200 Subject: [PATCH 476/768] Update release checklist --- RELEASING.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 4416ee0..1cc7147 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,23 +1,25 @@ # Release Checklist * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Remove `.dev0` suffix from the version: +* [ ] Remove `.dev0` suffix from the version and update version and date in the changelog: ```bash git checkout master edit pylast/version.py +edit CHANGELOG.md ``` * [ ] Commit and tag with the version number: ```bash -git add pylast/version.py -git commit -m "Release 2.1.0" -git tag -a 2.1.0 -m "Release 2.1.0" +git add CHANGELOG.md pylast/version.py +git commit -m "Release 3.0.0" +git tag -a 3.0.0 -m "Release 3.0.0" ``` * [ ] Create a distribution and release on PyPI: ```bash -pip install -U pip setuptools wheel twine keyring +pip3 install -U pip setuptools wheel twine keyring rm -rf build -python setup.py sdist --format=gztar bdist_wheel -twine upload -r pypi dist/pylast-2.1.0* +python3 setup.py sdist --format=gztar bdist_wheel +twine check dist/* +twine upload -r pypi dist/pylast-3.0.0* ``` * [ ] Check installation: `pip install -U pylast` * [ ] Push commits and tags: @@ -26,8 +28,8 @@ git push git push --tags ``` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "2.1.0" - * Title: "Release 2.1.0" + * Tag: Pick existing tag "3.0.0" + * Title: "Release 3.0.0" * [ ] Increment version and append `.dev0`: ```bash git checkout master From 9968d5f06e05a6d69f10456f1b4b6b39b5951e88 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 1 Jan 2019 18:05:12 +0200 Subject: [PATCH 477/768] Start new release cycle --- CHANGELOG.md | 5 +++-- pylast/version.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa942b5..a6697ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.0.0] - 2019-01-01 ### Added * This changelog file ([#273]) @@ -22,7 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) -[Unreleased]: https://github.com/pylast/pylast/compare/v2.4.0...HEAD +[Unreleased]: https://github.com/pylast/pylast/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 diff --git a/pylast/version.py b/pylast/version.py index 50b9eb9..094cc8f 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "3.0.0" +__version__ = "3.1.0.dev0" From 6134945df7e8ff77b82a0643b9eb65c8538482e7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 2 Jan 2019 14:43:52 +0200 Subject: [PATCH 478/768] Update release checklist --- RELEASING.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 1cc7147..ff3e862 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,5 +1,4 @@ # Release Checklist - * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. * [ ] Remove `.dev0` suffix from the version and update version and date in the changelog: ```bash @@ -21,7 +20,7 @@ python3 setup.py sdist --format=gztar bdist_wheel twine check dist/* twine upload -r pypi dist/pylast-3.0.0* ``` -* [ ] Check installation: `pip install -U pylast` +* [ ] Check installation: `pip3 uninstall -y pylast && pip3 install -U pylast` * [ ] Push commits and tags: ```bash git push From e77e0f9e35c04443d70045ff3bf3d42533b04525 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 5 Jan 2019 13:18:06 -0500 Subject: [PATCH 479/768] Extract username from session --- pylast/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index fd367ef..0924e69 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -201,10 +201,11 @@ class _Network: self.last_call_time = 0 self.limit_rate = False - # Load session_key from authentication token if provided + # Load session_key and username from authentication token if provided if token and not self.session_key: sk_gen = SessionKeyGenerator(self) - self.session_key = sk_gen.get_web_auth_session_key(url=None, token=token) + self.session_key, self.username = \ + sk_gen.get_web_auth_session_key(url=None, token=token) # Generate a session_key if necessary if ( @@ -988,7 +989,7 @@ class SessionKeyGenerator: b. sg = SessionKeyGenerator(network) c. url = sg.get_web_auth_url() d. Ask the user to open the URL and authorize you, and wait for it. - e. session_key = sg.get_web_auth_session_key(url) + e. session_key, username = sg.get_web_auth_session_key(url) 2) Username and Password Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. username = raw_input("Please enter your username: ") @@ -1062,7 +1063,9 @@ class SessionKeyGenerator: doc = request.execute() - return doc.getElementsByTagName("key")[0].firstChild.data + session_key = doc.getElementsByTagName("key")[0].firstChild.data + username = doc.getElementsByTagName("name")[0].firstChild.data + return session_key, username def get_session_key(self, username, password_hash): """ From aa8adfee4bd5e551db6763e6b926c10ca0cbd85e Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 5 Jan 2019 16:00:46 -0500 Subject: [PATCH 480/768] Create a separate method for extracting username --- pylast/__init__.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 0924e69..403f7ba 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -204,8 +204,9 @@ class _Network: # Load session_key and username from authentication token if provided if token and not self.session_key: sk_gen = SessionKeyGenerator(self) - self.session_key, self.username = \ - sk_gen.get_web_auth_session_key(url=None, token=token) + self.session_key, self.username = sk_gen.get_web_auth_session_key_and_username( + url=None, token=token + ) # Generate a session_key if necessary if ( @@ -989,7 +990,7 @@ class SessionKeyGenerator: b. sg = SessionKeyGenerator(network) c. url = sg.get_web_auth_url() d. Ask the user to open the URL and authorize you, and wait for it. - e. session_key, username = sg.get_web_auth_session_key(url) + e. session_key = sg.get_web_auth_session_key(url) 2) Username and Password Authentication: a. network = get_*_network(API_KEY, API_SECRET) b. username = raw_input("Please enter your username: ") @@ -1044,9 +1045,9 @@ class SessionKeyGenerator: return url - def get_web_auth_session_key(self, url, token=""): + def get_web_auth_session_key_and_username(self, url, token=""): """ - Retrieves the session key of a web authorization process by its URL. + Retrieves the session key and username of a web authorization process by its URL. """ if url in self.web_auth_tokens.keys(): @@ -1067,6 +1068,13 @@ class SessionKeyGenerator: username = doc.getElementsByTagName("name")[0].firstChild.data return session_key, username + def get_web_auth_session_key(self, url, token=""): + """ + Retrieves the session key of a web authorization process by its URL. + """ + session_key, _username = self.get_web_auth_session_key_and_username(url, token) + return session_key + def get_session_key(self, username, password_hash): """ Retrieve a session key with a username and a md5 hash of the user's From 0a38ac801d0983f5abf8d3aa1c9f9cccaafe0bba Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 5 Jan 2019 16:23:03 -0500 Subject: [PATCH 481/768] Ignore line length rules in favor of black formatting --- setup.cfg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 9adb3ce..257ddaf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,10 +2,10 @@ universal = 1 [flake8] -max_line_length = 88 +ignore = E501,W503 [metadata] license_file = COPYING [pycodestyle] -max_line_length = 88 +ignore = E501 From fffc64781153d67412b1c658784448a0d2ae3ee7 Mon Sep 17 00:00:00 2001 From: Jace Browning Date: Sat, 5 Jan 2019 20:10:51 -0500 Subject: [PATCH 482/768] Shorten line length --- pylast/__init__.py | 8 ++++---- setup.cfg | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 403f7ba..5b839c9 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -204,7 +204,7 @@ class _Network: # Load session_key and username from authentication token if provided if token and not self.session_key: sk_gen = SessionKeyGenerator(self) - self.session_key, self.username = sk_gen.get_web_auth_session_key_and_username( + self.session_key, self.username = sk_gen.get_web_auth_session_key_username( url=None, token=token ) @@ -1045,9 +1045,9 @@ class SessionKeyGenerator: return url - def get_web_auth_session_key_and_username(self, url, token=""): + def get_web_auth_session_key_username(self, url, token=""): """ - Retrieves the session key and username of a web authorization process by its URL. + Retrieves the session key/username of a web authorization process by its URL. """ if url in self.web_auth_tokens.keys(): @@ -1072,7 +1072,7 @@ class SessionKeyGenerator: """ Retrieves the session key of a web authorization process by its URL. """ - session_key, _username = self.get_web_auth_session_key_and_username(url, token) + session_key, _username = self.get_web_auth_session_key_username(url, token) return session_key def get_session_key(self, username, password_hash): diff --git a/setup.cfg b/setup.cfg index 257ddaf..0fa133b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,10 +2,11 @@ universal = 1 [flake8] -ignore = E501,W503 +ignore = W503 +max_line_length = 88 [metadata] license_file = COPYING [pycodestyle] -ignore = E501 +max_line_length = 88 From 1a31e4e0c38e504c426aabcf013daa95fc95d949 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 6 Jan 2019 15:39:12 +0200 Subject: [PATCH 483/768] Add #290 to changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6697ac..3f37251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added + +* Extract username from session via new + `SessionKeyGenerator.get_web_auth_session_key_username` ([#290]) + ## [3.0.0] - 2019-01-01 ### Added * This changelog file ([#273]) @@ -25,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/pylast/pylast/compare/v3.0.0...HEAD [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 +[#290]: https://github.com/pylast/pylast/pull/290 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 [#282]: https://github.com/pylast/pylast/pull/282 From 71e9871b1bdbbc6f5fc39fe5dbb8e3de44337aa4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 9 Jan 2019 16:19:22 +0200 Subject: [PATCH 484/768] sudo no longer needed https://blog.travis-ci.com/2018-11-19-required-linux-infrastructure-migration --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5b8e058..09e04bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,6 @@ matrix: - python: 3.7 env: TOXENV=py37 dist: xenial - sudo: true - python: 3.6 env: TOXENV=py36 - python: 3.5 From b30fe33cc122db0939f5ae53b5e71386eeb024a3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 Jan 2019 14:42:46 +0200 Subject: [PATCH 485/768] The future is now --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index a7e7e3d..f7c494f 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -from __future__ import print_function import sys from setuptools import find_packages, setup From 45a1b7c7f1ea043f6b0e9d9e0debe94f3da5685b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 Feb 2019 13:40:17 +0200 Subject: [PATCH 486/768] Test in random order to prevent collisions --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index a84fb2a..c06e586 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps = mock ipdb pytest-cov + pytest-random-order flaky commands = pytest -v -s -W all --cov pylast --cov-report term-missing {posargs} From 850d746b60019cab032249fa5014d845978211d5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 Feb 2019 13:54:18 +0200 Subject: [PATCH 487/768] Test in random order to prevent collisions --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c06e586..9e62b74 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ deps = pytest-cov pytest-random-order flaky -commands = pytest -v -s -W all --cov pylast --cov-report term-missing {posargs} +commands = pytest -v -s -W all --cov pylast --cov-report term-missing --random-order {posargs} [testenv:venv] deps = ipdb From cfb81348723a44310d3f0c0fd8585bb65ba46792 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 Feb 2019 14:15:31 +0200 Subject: [PATCH 488/768] Only run test_scrobble on a single Python version to avoid collisions --- tests/test_network.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_network.py b/tests/test_network.py index 3ce4951..ebe90ea 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +import sys import time import unittest @@ -11,6 +12,9 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): + @unittest.skipUnless( + sys.version_info[:2] == (3, 7), "Only run on Python 3.7 to avoid collisions" + ) def test_scrobble(self): # Arrange artist = "test artist" From d4fe9e5b36f8e8bd9d2f04407b735086c5dc59fa Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 12:42:50 +0200 Subject: [PATCH 489/768] Add a delay to allow Last.fm to update scrobbles --- tests/test_network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_network.py b/tests/test_network.py index ebe90ea..33c349e 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -26,6 +26,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.network.scrobble(artist=artist, title=title, timestamp=timestamp) # Assert + time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertEqual(str(last_scrobble.track.artist).lower(), artist) From 4eec2e763dd9dafeaca968dd7570de43117c76ce Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 12:51:44 +0200 Subject: [PATCH 490/768] Only run test_love on a single Python version to avoid collisions --- tests/test_network.py | 9 +++------ tests/test_pylast.py | 4 ++++ tests/test_track.py | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 33c349e..448e535 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,19 +2,16 @@ """ Integration (not unit) tests for pylast.py """ -import sys import time import unittest import pylast -from .test_pylast import TestPyLastWithLastFm +from .test_pylast import PY37, TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): - @unittest.skipUnless( - sys.version_info[:2] == (3, 7), "Only run on Python 3.7 to avoid collisions" - ) + @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") def test_scrobble(self): # Arrange artist = "test artist" @@ -24,9 +21,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Act self.network.scrobble(artist=artist, title=title, timestamp=timestamp) + time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert - time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertEqual(str(last_scrobble.track.artist).lower(), artist) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index bb88225..b417fb8 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -3,6 +3,7 @@ Integration (not unit) tests for pylast.py """ import os +import sys import time import unittest @@ -12,6 +13,9 @@ from flaky import flaky import pylast +PY37 = sys.version_info[:2] == (3, 7) + + def load_secrets(): secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): diff --git a/tests/test_track.py b/tests/test_track.py index b65d6be..bce28db 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -6,10 +6,11 @@ import unittest import pylast -from .test_pylast import TestPyLastWithLastFm +from .test_pylast import PY37, TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): + @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") def test_love(self): # Arrange artist = "Test Artist" From 7f207c50da4bea73831f3be25cd48bc85c5eeea9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 13:55:54 +0200 Subject: [PATCH 491/768] Add a delay to allow Last.fm to update unloves --- tests/test_track.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_track.py b/tests/test_track.py index bce28db..f0d0620 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +import time import unittest import pylast @@ -20,6 +21,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act track.love() + time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert loved = lastfm_user.get_loved_tracks(limit=1) From 0ad27dbc7ef221dc0667638a5de79d078b4578a7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 14:02:28 +0200 Subject: [PATCH 492/768] Only run test_unlove on a single Python version to avoid collisions --- tests/test_track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_track.py b/tests/test_track.py index f0d0620..274d495 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -11,7 +11,6 @@ from .test_pylast import PY37, TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): - @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") def test_love(self): # Arrange artist = "Test Artist" @@ -28,6 +27,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): self.assertEqual(str(loved[0].track.artist).lower(), "test artist") self.assertEqual(str(loved[0].track.title).lower(), "test title") + @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) From b41c5bc85aad44eb29e0192a141a5e6fbfe6e4f2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 14:39:21 +0200 Subject: [PATCH 493/768] Add a delay to allow Last.fm to update unloves --- tests/test_track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_track.py b/tests/test_track.py index 274d495..9c1b898 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -20,7 +20,6 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act track.love() - time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert loved = lastfm_user.get_loved_tracks(limit=1) @@ -38,6 +37,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act track.unlove() + time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert loved = lastfm_user.get_loved_tracks(limit=1) From deb25fb0305ad292eb45a0770a3dc027cc5bb4d8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 Feb 2019 13:05:27 +0200 Subject: [PATCH 494/768] Deploy to PyPI on master --- .travis.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 09e04bc..64d8a52 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,4 @@ language: python - cache: pip env: @@ -34,8 +33,9 @@ matrix: fast_finish: true install: -- travis_retry pip install tox -- travis_retry pip install coverage +- travis_retry pip install --upgrade pip +- travis_retry pip install --upgrade tox +- travis_retry pip install --upgrade coverage script: tox @@ -44,3 +44,27 @@ after_success: - travis_retry pip install codecov && codecov - travis_retry pip install scrutinizer-ocular && ocular +deploy: + - provider: pypi + server: https://test.pypi.org/legacy/ + on: + tags: false + repo: pylast/pylast + branch: master + python: 3.7 + user: hugovk + password: + secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= + distributions: sdist --format=gztar bdist_wheel + skip_existing: true + - provider: pypi + on: + tags: true + repo: pylast/pylast + branch: master + python: 3.7 + user: hugovk + password: + secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= + distributions: sdist --format=gztar bdist_wheel + skip_existing: true From 5406261679531722422928aff9cd8e6b5ee137b6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 2 Feb 2019 13:12:11 +0200 Subject: [PATCH 495/768] In case lint is done on 'python: 3.7', deploy on a TOXENV --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 64d8a52..676032a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -51,7 +51,7 @@ deploy: tags: false repo: pylast/pylast branch: master - python: 3.7 + condition: $TOXENV = py37 user: hugovk password: secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= @@ -62,7 +62,7 @@ deploy: tags: true repo: pylast/pylast branch: master - python: 3.7 + condition: $TOXENV = py37 user: hugovk password: secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= From 69b54e8c450086c6b564c0ee8aaea51d8512ba92 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 3 Feb 2019 20:56:42 +0200 Subject: [PATCH 496/768] Update encrypted password --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 676032a..4aea714 100644 --- a/.travis.yml +++ b/.travis.yml @@ -54,7 +54,7 @@ deploy: condition: $TOXENV = py37 user: hugovk password: - secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= + secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" distributions: sdist --format=gztar bdist_wheel skip_existing: true - provider: pypi @@ -65,6 +65,6 @@ deploy: condition: $TOXENV = py37 user: hugovk password: - secure: MR3i+/8ZscMkgDlAQkbZxnAuvmHebfGqZzih2nlL8DoINx063ewqISsxhFtAIuyuFPUFu1F2urz2oXVlhc5jxulwSMkjTRGEb4NCCGcZvvPrIQw7nazRXo57o4AkVYuFMne3Bu8YU1R5gugeJenG0WdDXtjZkHrSjX2YzWUyYgdRbC5/KG3XHaVLSNtRebICxMYNj4Py22qh/B9rvXoKMAcgJHYf/FeAZhnYf25xzZBK8oiRv2ELRPogbTCDaCuFz6XDFHcx5WfV5jNfjeYaKzKPiHZhcKbyVDGFODRKa98mVhDTaVOcq4oCysGP/uz96cu67FOxk7wHRzZf8HV6WTgh2n3Zvokkm+uXb4rQlteAdYPfgeqtcgKXIj9WmGL48dw2f72oGahLDjQEXfzJzX3LaRgAd99TMFsGpbKlU9mfmfz/SOXyUA7ZAB6iEw0TsYosoffblH/q+1BjE0nIhY9xoP3KOz44TtUUSdtW4ztRQXrGzZkCiNqbSmZwqL+IhJRSHHsxggDaUI0N2c2f10IJHVUDgMjz4IjutdXgcwRTe8UCzQ/maeRI0epZAtC9UQ6ix5zKlR20IIzhP0rOinhk3vKCpINzofk46fkgKC7tM7Lchip0URQ6vnQxhG99wn7MZbOgTNp7fZduaFEfe72oIjCfDv1nTrRkr0dI5Cc= + secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" distributions: sdist --format=gztar bdist_wheel skip_existing: true From e38d1c516b1e841dc24548f76c5ef51e5b7ec41b Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 9 Feb 2019 23:18:51 +0200 Subject: [PATCH 497/768] Update error status codes from https://www.last.fm/api/errorcodes --- pylast/__init__.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 5b839c9..c4da3a6 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -72,13 +72,16 @@ else: from urllib.parse import quote_plus as url_quote_plus +# 1 : This error does not exist STATUS_INVALID_SERVICE = 2 STATUS_INVALID_METHOD = 3 STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 -STATUS_TOKEN_ERROR = 8 +# DeprecationWarning: STATUS_TOKEN_ERROR is deprecated and will be +# removed in a future version. Use STATUS_OPERATION_FAILED instead. +STATUS_OPERATION_FAILED = STATUS_TOKEN_ERROR = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 @@ -86,6 +89,20 @@ STATUS_SUBSCRIBERS_ONLY = 12 STATUS_INVALID_SIGNATURE = 13 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 +STATUS_TEMPORARILY_UNAVAILABLE = 16 +STATUS_LOGIN_REQUIRED = 17 +STATUS_TRIAL_EXPIRED = 18 +# 19 : This error does not exist +STATUS_NOT_ENOUGH_CONTENT = 20 +STATUS_NOT_ENOUGH_MEMBERS = 21 +STATUS_NOT_ENOUGH_FANS = 22 +STATUS_NOT_ENOUGH_NEIGHBOURS = 23 +STATUS_NO_PEAK_RADIO = 24 +STATUS_RADIO_NOT_FOUND = 25 +STATUS_API_KEY_SUSPENDED = 26 +STATUS_DEPRECATED = 27 +# 28 : This error is not documented +STATUS_RATE_LIMIT_EXCEEDED = 29 PERIOD_OVERALL = "overall" PERIOD_7DAYS = "7day" @@ -1414,13 +1431,25 @@ class WSError(Exception): STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 - STATUS_TOKEN_ERROR = 8 + STATUS_OPERATION_FAILED = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 STATUS_SUBSCRIBERS_ONLY = 12 STATUS_TOKEN_UNAUTHORIZED = 14 STATUS_TOKEN_EXPIRED = 15 + STATUS_TEMPORARILY_UNAVAILABLE = 16 + STATUS_LOGIN_REQUIRED = 17 + STATUS_TRIAL_EXPIRED = 18 + STATUS_NOT_ENOUGH_CONTENT = 20 + STATUS_NOT_ENOUGH_MEMBERS = 21 + STATUS_NOT_ENOUGH_FANS = 22 + STATUS_NOT_ENOUGH_NEIGHBOURS = 23 + STATUS_NO_PEAK_RADIO = 24 + STATUS_RADIO_NOT_FOUND = 25 + STATUS_API_KEY_SUSPENDED = 26 + STATUS_DEPRECATED = 27 + STATUS_RATE_LIMIT_EXCEEDED = 29 """ return self.status From bfd3ffe06cb1ebd22587d362292572cb2d957c4e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 10 Feb 2019 11:31:42 +0200 Subject: [PATCH 498/768] Retry on temporary error when paging --- pylast/__init__.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index c4da3a6..48f918a 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2776,7 +2776,29 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): while not end_of_pages and (not limit or (limit and len(nodes) < limit)): params["page"] = str(page) - doc = sender._request(method_name, cacheable, params) + + tries = 0 + while True: + tries += 1 + try: + doc = sender._request(method_name, cacheable, params) + break # success + except MalformedResponseError as e: + if tries < 3: + time.sleep(1) # wait and try again + else: + raise e + except WSError as e: + if tries < 3 and int(e.get_id()) in [ + # "Please try again" statuses + STATUS_OPERATION_FAILED, + STATUS_OFFLINE, + STATUS_TEMPORARILY_UNAVAILABLE, + ]: + time.sleep(1) # wait and try again + else: + raise e + doc = cleanup_nodes(doc) # break if there are no child nodes From f32848160cc87513b6f80fd7e3cd79fba1183b01 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 10 Feb 2019 11:58:40 +0200 Subject: [PATCH 499/768] Make test more reliable: check a static album not a dynamic one which may not have any tags --- tests/test_album.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index a2836fd..2a26849 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -12,10 +12,10 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): def test_album_tags_are_topitems(self): # Arrange - albums = self.network.get_user("RJ").get_top_albums() + album = self.network.get_album("Test Artist", "Test Album") # Act - tags = albums[0].item.get_top_tags(limit=1) + tags = album.get_top_tags(limit=1) # Assert self.assertGreater(len(tags), 0) From 9b2ada5dd03b1f8294497912320413c6735a9f2d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 10 Feb 2019 16:06:58 +0200 Subject: [PATCH 500/768] Retry on any exception Also seen these when testing: WSError: User not found MalformedResponseError: Malformed response from Last.fm. Underlying error: mismatched tag: line 6, column 2 NetworkError: NetworkError: [Errno 8] nodename nor servname provided, or not known --- pylast/__init__.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index 48f918a..dc71cf5 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2777,27 +2777,17 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None): while not end_of_pages and (not limit or (limit and len(nodes) < limit)): params["page"] = str(page) - tries = 0 + tries = 1 while True: - tries += 1 try: doc = sender._request(method_name, cacheable, params) break # success - except MalformedResponseError as e: - if tries < 3: - time.sleep(1) # wait and try again - else: - raise e - except WSError as e: - if tries < 3 and int(e.get_id()) in [ - # "Please try again" statuses - STATUS_OPERATION_FAILED, - STATUS_OFFLINE, - STATUS_TEMPORARILY_UNAVAILABLE, - ]: - time.sleep(1) # wait and try again - else: + except Exception as e: + if tries >= 3: raise e + # Wait and try again + time.sleep(1) + tries += 1 doc = cleanup_nodes(doc) From 86c979204dd61d3f8f96706e794ca123ec5b2f04 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 10 Feb 2019 17:30:04 +0200 Subject: [PATCH 501/768] year++ --- pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylast/__init__.py b/pylast/__init__.py index dc71cf5..7308907 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -4,7 +4,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2018 hugovk +# Copyright 2013-2019 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -37,7 +37,7 @@ from . import version __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = ( - "Copyright (C) 2008-2010 Amr Hassan, 2013-2018 hugovk, " "2017 Mice Pápai" + "Copyright (C) 2008-2010 Amr Hassan, 2013-2019 hugovk, " "2017 Mice Pápai" ) __license__ = "apache2" __email__ = "amr.hassan@gmail.com" From 9d125506e5c28a7227c74f37dec0ca5575c78a67 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 26 Feb 2019 22:53:31 +0200 Subject: [PATCH 502/768] Deprecate User.get_artist_tracks as Last.fm will remove it soon --- CHANGELOG.md | 5 +++++ pylast/__init__.py | 12 ++++++++++-- tests/test_album.py | 5 ++++- tests/test_user.py | 9 ++++++--- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f37251..a36d432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Extract username from session via new `SessionKeyGenerator.get_web_auth_session_key_username` ([#290]) +### Deprecated + +* `User.get_artist_tracks` ([#298]) + ## [3.0.0] - 2019-01-01 ### Added * This changelog file ([#273]) @@ -31,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/pylast/pylast/compare/v3.0.0...HEAD [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 +[#298]: https://github.com/pylast/pylast/issues/298 [#290]: https://github.com/pylast/pylast/pull/290 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 diff --git a/pylast/__init__.py b/pylast/__init__.py index 5b839c9..1cd1a2e 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -20,18 +20,19 @@ # # https://github.com/pylast/pylast -from xml.dom import minidom, Node import collections import hashlib +import html.entities import logging import shelve import ssl import sys import tempfile import time +import warnings import xml.dom -import html.entities from http.client import HTTPSConnection +from xml.dom import Node, minidom from . import version @@ -2236,6 +2237,13 @@ class User(_BaseObject, _Chartable): # Not implemented: # "Can be limited to specific timeranges, defaults to all time." + warnings.warn( + "User.get_artist_tracks is deprecated and will be removed in a future " + "version: https://github.com/pylast/pylast/issues/298", + DeprecationWarning, + stacklevel=2, + ) + params = self._get_params() params["artist"] = artist diff --git a/tests/test_album.py b/tests/test_album.py index a2836fd..c855d48 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -3,6 +3,7 @@ Integration (not unit) tests for pylast.py """ import unittest +import warnings import pylast @@ -44,7 +45,9 @@ class TestPyLastAlbum(TestPyLastWithLastFm): lastfm_user = self.network.get_user(self.username) # Act - track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] # Assert self.assertTrue(hasattr(track, "album")) diff --git a/tests/test_user.py b/tests/test_user.py index 1e7c76d..bd019ea 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -4,6 +4,7 @@ Integration (not unit) tests for pylast.py """ import os import unittest +import warnings import pylast @@ -186,9 +187,11 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_authenticated_user() # Act - result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) - result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) - result3 = lastfm_user.get_artist_tracks("Test Artist") + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) + result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) + result3 = lastfm_user.get_artist_tracks("Test Artist") # Assert self.helper_validate_results(result1, result2, result3) From 65e182c5ddfad8b3e097ed75ff92a8c83fcd883e Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Feb 2019 17:01:23 +0200 Subject: [PATCH 503/768] Aside: make test_scrobble more reliable --- tests/test_network.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 448e535..36188ec 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -20,15 +20,14 @@ class TestPyLastNetwork(TestPyLastWithLastFm): lastfm_user = self.network.get_user(self.username) # Act + self.network.scrobble(artist=artist, title="test title 2", timestamp=timestamp) self.network.scrobble(artist=artist, title=title, timestamp=timestamp) - time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] self.assertEqual(str(last_scrobble.track.artist).lower(), artist) self.assertEqual(str(last_scrobble.track.title).lower(), title) - self.assertEqual(str(last_scrobble.timestamp), str(timestamp)) def test_update_now_playing(self): # Arrange From dc464788f22a3cbf79330c206f6ac6e26700b11b Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Feb 2019 17:19:20 +0200 Subject: [PATCH 504/768] Aside: make test_album_tags_are_topitems more reliable --- tests/test_album.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index c855d48..2d47720 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -13,10 +13,10 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): def test_album_tags_are_topitems(self): # Arrange - albums = self.network.get_user("RJ").get_top_albums() + album = self.network.get_album("Test Artist", "Test Album") # Act - tags = albums[0].item.get_top_tags(limit=1) + tags = album.get_top_tags(limit=1) # Assert self.assertGreater(len(tags), 0) From da473448f48ce1cb7a9b1aae95ea66e84e1c3337 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Feb 2019 19:47:31 +0200 Subject: [PATCH 505/768] Add support for new user.getTrackScrobbles --- pylast/__init__.py | 26 ++++++++++++++++++++++++++ tests/test_user.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/pylast/__init__.py b/pylast/__init__.py index 1cd1a2e..3d1e4ab 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2533,6 +2533,32 @@ class User(_BaseObject, _Chartable): return self._get_things("getTopTracks", "track", Track, params, cacheable) + def get_track_scrobbles(self, artist, track, cacheable=False): + """ + Get a list of scrobbles of this tracks by this artist scrobbled by this user, + including scrobble time. + """ + + params = self._get_params() + params["artist"] = artist + params["track"] = track + + seq = [] + for track in _collect_nodes( + None, self, self.ws_prefix + ".getTrackScrobbles", cacheable, params + ): + title = _extract(track, "name") + artist = _extract(track, "artist") + date = _extract(track, "date") + album = _extract(track, "album") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + + seq.append( + PlayedTrack(Track(artist, title, self.network), album, date, timestamp) + ) + + return seq + def get_image(self, size=SIZE_EXTRA_LARGE): """ Returns the user's avatar diff --git a/tests/test_user.py b/tests/test_user.py index bd019ea..cf37c09 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -433,6 +433,34 @@ class TestPyLastUser(TestPyLastWithLastFm): self.assertIsNotNone(track) self.assertIsInstance(track.network, pylast.LastFMNetwork) + def test_user_get_track_scrobbles(self): + # Arrange + artist = "France Gall" + title = "Laisse Tomber Les Filles" + user = self.network.get_user("bbc6music") + + # Act + scrobbles = user.get_track_scrobbles(artist, title) + + # Assert + self.assertGreater(len(scrobbles), 0) + self.assertEqual(str(scrobbles[0].track.artist), "France Gall") + self.assertEqual(scrobbles[0].track.title, "Laisse Tomber Les Filles") + + def test_cacheable_user_get_track_scrobbles(self): + # Arrange + artist = "France Gall" + title = "Laisse Tomber Les Filles" + user = self.network.get_user("bbc6music") + + # Act + result1 = user.get_track_scrobbles(artist, title, cacheable=False) + result2 = user.get_track_scrobbles(artist, title, cacheable=True) + result3 = user.get_track_scrobbles(artist, title) + + # Assert + self.helper_validate_results(result1, result2, result3) + if __name__ == "__main__": unittest.main(failfast=True) From 2284dcf5801e9576aea3e7300adacb86705aa3a8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 27 Feb 2019 19:56:29 +0200 Subject: [PATCH 506/768] Update docs --- CHANGELOG.md | 4 +++- pylast/__init__.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a36d432..8c7eb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Extract username from session via new `SessionKeyGenerator.get_web_auth_session_key_username` ([#290]) +* `User.get_track_scrobbles` ([#298]) ### Deprecated -* `User.get_artist_tracks` ([#298]) +* `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement. + ([#298]) ## [3.0.0] - 2019-01-01 ### Added diff --git a/pylast/__init__.py b/pylast/__init__.py index 3d1e4ab..b0c2fda 100644 --- a/pylast/__init__.py +++ b/pylast/__init__.py @@ -2239,7 +2239,8 @@ class User(_BaseObject, _Chartable): warnings.warn( "User.get_artist_tracks is deprecated and will be removed in a future " - "version: https://github.com/pylast/pylast/issues/298", + "version. User.get_track_scrobbles is a partial replacement. " + "See https://github.com/pylast/pylast/issues/298", DeprecationWarning, stacklevel=2, ) @@ -2535,7 +2536,7 @@ class User(_BaseObject, _Chartable): def get_track_scrobbles(self, artist, track, cacheable=False): """ - Get a list of scrobbles of this tracks by this artist scrobbled by this user, + Get a list of this user's scrobbles of this artist's track, including scrobble time. """ From 694f67b25aa6f0f0d9d5733a1d6c2df8c95ab7ef Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 Mar 2019 09:27:32 +0200 Subject: [PATCH 507/768] Release 3.1.0 --- CHANGELOG.md | 4 ++-- pylast/version.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7eb76..ce280f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.1.0] - 2019-03-07 ### Added * Extract username from session via new @@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) -[Unreleased]: https://github.com/pylast/pylast/compare/v3.0.0...HEAD +[3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 [#298]: https://github.com/pylast/pylast/issues/298 diff --git a/pylast/version.py b/pylast/version.py index 094cc8f..e19c775 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "3.1.0.dev0" +__version__ = "3.1.0" From a5c53dbf5402535ca79c66cebe1ca1e1a3dc7baa Mon Sep 17 00:00:00 2001 From: hugovk Date: Thu, 7 Mar 2019 09:44:45 +0200 Subject: [PATCH 508/768] Start new release cycle --- CHANGELOG.md | 5 ++++- RELEASING.md | 25 +++++++++++-------------- pylast/version.py | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce280f1..2bf959b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### TODO + ## [3.1.0] - 2019-03-07 ### Added @@ -33,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) - +[Unreleased]: https://github.com/pylast/pylast/compare/v3.1.0...HEAD [3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 diff --git a/RELEASING.md b/RELEASING.md index ff3e862..e615ace 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -3,8 +3,7 @@ * [ ] Remove `.dev0` suffix from the version and update version and date in the changelog: ```bash git checkout master -edit pylast/version.py -edit CHANGELOG.md +edit CHANGELOG.md pylast/version.py ``` * [ ] Commit and tag with the version number: ```bash @@ -12,31 +11,29 @@ git add CHANGELOG.md pylast/version.py git commit -m "Release 3.0.0" git tag -a 3.0.0 -m "Release 3.0.0" ``` -* [ ] Create a distribution and release on PyPI: -```bash -pip3 install -U pip setuptools wheel twine keyring -rm -rf build -python3 setup.py sdist --format=gztar bdist_wheel -twine check dist/* -twine upload -r pypi dist/pylast-3.0.0* -``` -* [ ] Check installation: `pip3 uninstall -y pylast && pip3 install -U pylast` + * [ ] Push commits and tags: ```bash git push git push --tags ``` + * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new * Tag: Pick existing tag "3.0.0" * Title: "Release 3.0.0" -* [ ] Increment version and append `.dev0`: + +* [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has deployed to [PyPI](https://pypi.org/project/pylast/#history) + +* [ ] Check installation: `pip3 uninstall -y pylast && pip3 install -U pylast` + +* [ ] Increment version and append `.dev0`, and add Unreleased to the changelog: ```bash git checkout master -edit pylast/version.py +edit CHANGELOG.md pylast/version.py ``` * [ ] Commit and push: ```bash -git add pylast/version.py +git add CHANGELOG.md pylast/version.py git commit -m "Start new release cycle" git push ``` diff --git a/pylast/version.py b/pylast/version.py index e19c775..aed691c 100644 --- a/pylast/version.py +++ b/pylast/version.py @@ -1,2 +1,2 @@ # Master version for pylast -__version__ = "3.1.0" +__version__ = "3.2.0.dev0" From 1a78c0b99d0103d83c7942a9675c16614eb1ecba Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 5 Apr 2019 08:37:01 +0300 Subject: [PATCH 509/768] Move production code to src/ --- MANIFEST.in | 2 +- RELEASING.md | 9 +++++---- setup.py | 5 +++-- {pylast => src/pylast}/__init__.py | 0 {pylast => src/pylast}/version.py | 0 5 files changed, 9 insertions(+), 7 deletions(-) rename {pylast => src/pylast}/__init__.py (100%) rename {pylast => src/pylast}/version.py (100%) diff --git a/MANIFEST.in b/MANIFEST.in index a82ada5..4aef3a7 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include pylast/*.py +include src/pylast/*.py include setup.py include README.md include COPYING diff --git a/RELEASING.md b/RELEASING.md index e615ace..a89d169 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,13 +1,14 @@ # Release Checklist + * [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. * [ ] Remove `.dev0` suffix from the version and update version and date in the changelog: ```bash git checkout master -edit CHANGELOG.md pylast/version.py +edit CHANGELOG.md src/pylast/version.py ``` * [ ] Commit and tag with the version number: ```bash -git add CHANGELOG.md pylast/version.py +git add CHANGELOG.md src/pylast/version.py git commit -m "Release 3.0.0" git tag -a 3.0.0 -m "Release 3.0.0" ``` @@ -29,11 +30,11 @@ git push --tags * [ ] Increment version and append `.dev0`, and add Unreleased to the changelog: ```bash git checkout master -edit CHANGELOG.md pylast/version.py +edit CHANGELOG.md src/pylast/version.py ``` * [ ] Commit and push: ```bash -git add CHANGELOG.md pylast/version.py +git add CHANGELOG.md src/pylast/version.py git commit -m "Start new release cycle" git push ``` diff --git a/setup.py b/setup.py index f7c494f..c718213 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import sys from setuptools import find_packages, setup version_dict = {} -with open("pylast/version.py") as f: +with open("src/pylast/version.py") as f: exec(f.read(), version_dict) version = version_dict["__version__"] @@ -72,7 +72,8 @@ setup( "Programming Language :: Python :: Implementation :: PyPy", ], keywords=["Last.fm", "music", "scrobble", "scrobbling"], - packages=find_packages(exclude=("tests*",)), + packages=find_packages(where="src"), + package_dir={"": "src"}, license="Apache2", ) diff --git a/pylast/__init__.py b/src/pylast/__init__.py similarity index 100% rename from pylast/__init__.py rename to src/pylast/__init__.py diff --git a/pylast/version.py b/src/pylast/version.py similarity index 100% rename from pylast/version.py rename to src/pylast/version.py From c3b36433b2074a64bc3e45f9e8f32b745c2734bc Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 5 Apr 2019 09:00:31 +0300 Subject: [PATCH 510/768] Don't fail due to Last.fm bug --- tests/test_album.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_album.py b/tests/test_album.py index 2d47720..09b1f8f 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -2,6 +2,7 @@ """ Integration (not unit) tests for pylast.py """ +import pytest import unittest import warnings @@ -11,6 +12,10 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): + + # Last.fm bug + # https://getsatisfaction.com/lastfm/topics/gettoptags-getting-wrong-tags + @pytest.mark.xfail def test_album_tags_are_topitems(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") From b9ef29fa21ba3a9c34fb051d28144273e88820fe Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 5 Apr 2019 10:48:58 +0300 Subject: [PATCH 511/768] Simplify via https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 4aef3a7..82c478f 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include src/pylast/*.py +graft src include setup.py include README.md include COPYING From ab77165ecc923fbd13a3bf215232c0e9bcb90ab7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 14 Apr 2019 18:35:10 +0300 Subject: [PATCH 512/768] Revert "Don't fail due to Last.fm bug" This reverts commit c3b36433b2074a64bc3e45f9e8f32b745c2734bc. --- tests/test_album.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index 09b1f8f..2d47720 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -2,7 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import pytest import unittest import warnings @@ -12,10 +11,6 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): - - # Last.fm bug - # https://getsatisfaction.com/lastfm/topics/gettoptags-getting-wrong-tags - @pytest.mark.xfail def test_album_tags_are_topitems(self): # Arrange album = self.network.get_album("Test Artist", "Test Album") From 05a7fc07f1ad2fcfd73ba7752ade2ea947c0ca0d Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 4 May 2019 20:57:05 +0300 Subject: [PATCH 513/768] Black is now under the PSF umbrella https://twitter.com/llanga/status/1123980466292445190 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 457345c..c404593 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/black/black) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). From ce9ab4213ecb4d38133a4f0c9082cb16be7f6e98 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 10 May 2019 09:02:53 +0300 Subject: [PATCH 514/768] Remove deprecated license_file from setup.cfg --- setup.cfg | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0fa133b..3d9c412 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,8 +5,5 @@ universal = 1 ignore = W503 max_line_length = 88 -[metadata] -license_file = COPYING - [pycodestyle] max_line_length = 88 From 9ec4e99271fbbe86ee2a4552637680fa87159e3f Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 10 May 2019 09:03:03 +0300 Subject: [PATCH 515/768] Update Black link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c404593..cb8d2de 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ pyLast [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/black/black) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). From f439f205dc4c407d010ceb953d9dd4683c41a51b Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 13 May 2019 11:08:18 +0300 Subject: [PATCH 516/768] Xfail user.getArtistTracks, looks like Last.fm are now removing it from the API --- tests/test_album.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_album.py b/tests/test_album.py index 2d47720..c1eb4a8 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -5,6 +5,8 @@ Integration (not unit) tests for pylast.py import unittest import warnings +import pytest + import pylast from .test_pylast import TestPyLastWithLastFm @@ -40,6 +42,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert self.assertTrue(hasattr(track, "album")) + @pytest.mark.skip(reason="Last.fm is removing from API") def test_album_in_artist_tracks(self): # Arrange lastfm_user = self.network.get_user(self.username) From 44d0c2c966a34e9eb97bb8c2cd1af630d3d72111 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 2 Jun 2019 22:54:03 +0300 Subject: [PATCH 517/768] user.getArtistTracks has now been removed from Last.fm and returns an error --- src/pylast/__init__.py | 3 +-- tests/test_album.py | 16 ---------------- tests/test_user.py | 11 +++++++++++ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 6cbea87..93c8c49 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2260,11 +2260,10 @@ class User(_BaseObject, _Chartable): def get_artist_tracks(self, artist, cacheable=False): """ + Deprecated by Last.fm. Get a list of tracks by a given artist scrobbled by this user, including scrobble time. """ - # Not implemented: - # "Can be limited to specific timeranges, defaults to all time." warnings.warn( "User.get_artist_tracks is deprecated and will be removed in a future " diff --git a/tests/test_album.py b/tests/test_album.py index c1eb4a8..878e4e1 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -3,9 +3,6 @@ Integration (not unit) tests for pylast.py """ import unittest -import warnings - -import pytest import pylast @@ -42,19 +39,6 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert self.assertTrue(hasattr(track, "album")) - @pytest.mark.skip(reason="Last.fm is removing from API") - def test_album_in_artist_tracks(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - track = lastfm_user.get_artist_tracks(artist="Test Artist")[0] - - # Assert - self.assertTrue(hasattr(track, "album")) - def test_album_wiki_content(self): # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) diff --git a/tests/test_user.py b/tests/test_user.py index cf37c09..1154fdf 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -461,6 +461,17 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_validate_results(result1, result2, result3) + def test_get_artist_tracks_deprecated(self): + # Arrange + lastfm_user = self.network.get_user(self.username) + + # Act / Assert + with warnings.catch_warnings(), self.assertRaisesRegex( + pylast.WSError, "Deprecated - This type of request is no longer supported" + ): + warnings.filterwarnings("ignore", category=DeprecationWarning) + lastfm_user.get_artist_tracks(artist="Test Artist") + if __name__ == "__main__": unittest.main(failfast=True) From c7b927d94cfb4aea93db19f6c545969cd150e145 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 3 Jun 2019 16:45:54 +0300 Subject: [PATCH 518/768] user.getArtistTracks has been removed from Last.fm --- tests/test_user.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 1154fdf..e648962 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -182,20 +182,6 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.assertEqual(lastfm_user, loaded_user) - def test_cacheable_user_artist_tracks(self): - # Arrange - lastfm_user = self.network.get_authenticated_user() - - # Act - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - result1 = lastfm_user.get_artist_tracks("Test Artist", cacheable=False) - result2 = lastfm_user.get_artist_tracks("Test Artist", cacheable=True) - result3 = lastfm_user.get_artist_tracks("Test Artist") - - # Assert - self.helper_validate_results(result1, result2, result3) - def test_cacheable_user(self): # Arrange lastfm_user = self.network.get_authenticated_user() From 7694de346eeae20b5d04ce922debe1207d0b9e6c Mon Sep 17 00:00:00 2001 From: Paddez Date: Sat, 22 Jun 2019 11:04:09 +0100 Subject: [PATCH 519/768] Store Album Art URLs when you call GetTopAlbums. GetTopAlbums returns AlbumArt information alongside the TopAlbums. We should store this in the Album() info variable, so we don't have to follow up with an additional GetInfo() call if we want the Album Art. Signed-off-by: Paddez --- src/pylast/__init__.py | 3 ++- tests/test_user.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 93c8c49..c5d7e05 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2896,8 +2896,9 @@ def _extract_top_albums(doc, network): name = _extract(node, "name") artist = _extract(node, "name", 1) playcount = _extract(node, "playcount") + info = {"image": _extract_all(node, "image")} - seq.append(TopItem(Album(artist, name, network), playcount)) + seq.append(TopItem(Album(artist, name, network, info=info), playcount)) return seq diff --git a/tests/test_user.py b/tests/test_user.py index e648962..a8b724d 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -271,6 +271,12 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) + top_album = albums[0].item + self.assertTrue(len(top_album.info["image"])) + self.assertRegexpMatches( + top_album.info["image"][pylast.SIZE_LARGE], r"^http.+$" + ) + def test_user_tagged_artists(self): # Arrange lastfm_user = self.network.get_user(self.username) From 7915d7f3e68df00abdb6bf3ba50510e19f65e994 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 19 Sep 2019 14:27:20 +0300 Subject: [PATCH 520/768] Respect get_recent_tracks' limit when there's a now playing track --- src/pylast/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c5d7e05..7e8ad33 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2381,7 +2381,7 @@ class User(_BaseObject, _Chartable): params = self._get_params() if limit: - params["limit"] = limit + params["limit"] = limit + 1 # in case we remove the now playing track if time_from: params["from"] = time_from if time_to: @@ -2389,7 +2389,7 @@ class User(_BaseObject, _Chartable): seq = [] for track in _collect_nodes( - limit, self, self.ws_prefix + ".getRecentTracks", cacheable, params + limit + 1, self, self.ws_prefix + ".getRecentTracks", cacheable, params ): if track.hasAttribute("nowplaying"): @@ -2405,7 +2405,8 @@ class User(_BaseObject, _Chartable): PlayedTrack(Track(artist, title, self.network), album, date, timestamp) ) - return seq + # Slice, in case we didn't remove a now playing track + return seq[:limit] def get_country(self): """Returns the name of the country of the user.""" From d589c5ef8ae64194b9a750a238ea1dd19fdc59d6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 26 Sep 2019 10:31:49 +0300 Subject: [PATCH 521/768] Add pre-commit and fix isort --- .pre-commit-config.yaml | 36 ++++++++++++++++++++++++++++++++++++ .travis.yml | 2 -- tests/test_librefm.py | 3 +-- tests/test_pylast.py | 4 +--- tests/unicode_test.py | 3 +-- tox.ini | 10 +++------- 6 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ffb89cb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v1.24.0 + hooks: + - id: pyupgrade + + - repo: https://github.com/psf/black + rev: 19.3b0 + hooks: + - id: black + language_version: python3.7 + # override until resolved: https://github.com/psf/black/issues/402 + files: \.pyi?$ + types: [] + + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.8 + hooks: + - id: flake8 + additional_dependencies: [flake8-2020] + + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort + language_version: python3.7 + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.4.1 + hooks: + - id: python-check-blanket-noqa + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml diff --git a/.travis.yml b/.travis.yml index 4aea714..36bcc05 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,6 @@ matrix: env: TOXENV=lint - python: 3.7 env: TOXENV=py37 - dist: xenial - python: 3.6 env: TOXENV=py36 - python: 3.5 @@ -27,7 +26,6 @@ matrix: env: TOXENV=pypy3 - python: 3.8-dev env: TOXENV=py38dev - dist: xenial allow_failures: - env: TOXENV=pypy3 fast_finish: true diff --git a/tests/test_librefm.py b/tests/test_librefm.py index dba0109..7f8d54b 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -4,9 +4,8 @@ Integration (not unit) tests for pylast.py """ import unittest -from flaky import flaky - import pylast +from flaky import flaky from .test_pylast import PyLastTestCase, load_secrets diff --git a/tests/test_pylast.py b/tests/test_pylast.py index b417fb8..75fb379 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -7,12 +7,10 @@ import sys import time import unittest +import pylast import pytest from flaky import flaky -import pylast - - PY37 = sys.version_info[:2] == (3, 7) diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 062f37f..11e7ff7 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- import mock -import pytest - import pylast +import pytest def mock_network(): diff --git a/tox.ini b/tox.ini index 9e62b74..a2eeafb 100644 --- a/tox.ini +++ b/tox.ini @@ -23,10 +23,6 @@ deps = ipdb commands = {posargs} [testenv:lint] -deps = - flake8 - pep8-naming - black -commands = - flake8 . - black --check --diff . +deps = pre-commit +commands = pre-commit run --all-files +skip_install = true From 3fd8280e33cc1c676eb84c004fbc822e65f5db0e Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 26 Sep 2019 10:34:22 +0300 Subject: [PATCH 522/768] Update docstring: since 2016 get_weekly_artist_charts is only for users, not tags --- src/pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 7e8ad33..1d3f0ff 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1251,7 +1251,7 @@ class _Chartable: """ Returns the weekly artist charts for the week starting from the from_date value to the to_date value. - Only for Tag or User. + Only for User. """ return self.get_weekly_charts("artist", from_date, to_date) From 99c434c0efc1d82f1282b1b52a5f95eada25a728 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 26 Sep 2019 10:36:16 +0300 Subject: [PATCH 523/768] Run lint on Python 3.7 --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 36bcc05..1a5a9fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,8 +13,9 @@ env: - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= matrix: + fast_finish: true include: - - python: 3.6 + - python: 3.7 env: TOXENV=lint - python: 3.7 env: TOXENV=py37 @@ -28,7 +29,6 @@ matrix: env: TOXENV=py38dev allow_failures: - env: TOXENV=pypy3 - fast_finish: true install: - travis_retry pip install --upgrade pip @@ -49,7 +49,7 @@ deploy: tags: false repo: pylast/pylast branch: master - condition: $TOXENV = py37 + condition: $TOXENV = lint user: hugovk password: secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" @@ -60,7 +60,7 @@ deploy: tags: true repo: pylast/pylast branch: master - condition: $TOXENV = py37 + condition: $TOXENV = lint user: hugovk password: secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" From 109315d93dcd7137b27970b93b88ccdf84a28faa Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 26 Sep 2019 11:10:52 +0300 Subject: [PATCH 524/768] Remove Python 2 warnings, apply lint updates --- .pre-commit-config.yaml | 1 + setup.py | 27 --------------------------- src/pylast/__init__.py | 37 ++++--------------------------------- tests/unicode_test.py | 1 - 4 files changed, 5 insertions(+), 61 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffb89cb..648afd7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,6 +3,7 @@ repos: rev: v1.24.0 hooks: - id: pyupgrade + args: ["--py3-plus"] - repo: https://github.com/psf/black rev: 19.3b0 diff --git a/setup.py b/setup.py index c718213..37d38d2 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,4 @@ #!/usr/bin/env python -import sys - from setuptools import find_packages, setup version_dict = {} @@ -9,31 +7,6 @@ with open("src/pylast/version.py") as f: version = version_dict["__version__"] -if sys.version_info < (3, 5): - error = """pylast 3.0 and above are no longer compatible with Python 2. - -This is pylast {} and you are using Python {}. -Make sure you have pip >= 9.0 and setuptools >= 24.2 and retry: - - $ pip install --upgrade pip setuptools - -Other choices: - -- Upgrade to Python 3. - -- Install an older version of pylast: - -$ pip install 'pylast<3.0' - -For more information: - -https://github.com/pylast/pylast/issues/265 -""".format( - version, ".".join([str(v) for v in sys.version_info[:3]]) - ) - print(error, file=sys.stderr) - sys.exit(1) - with open("README.md") as f: long_description = f.read() diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 1d3f0ff..18fe9a6 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # pylast - # A Python interface to Last.fm and Libre.fm @@ -26,12 +25,12 @@ import html.entities import logging import shelve import ssl -import sys import tempfile import time import warnings import xml.dom from http.client import HTTPSConnection +from urllib.parse import quote_plus from xml.dom import Node, minidom from . import version @@ -44,34 +43,6 @@ __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = version.__version__ -if sys.version_info < (3,): - raise ImportError( - """pylast 3.0 and above are no longer compatible with Python 2. - -This is pylast {} and you are using Python {}. -Make sure you have pip >= 9.0 and setuptools >= 24.2 and retry: - - $ pip install --upgrade pip setuptools - -Other choices: - -- Upgrade to Python 3. - -- Install an older version of pylast: - -$ pip install 'pylast<3.0' - -For more information: - -https://github.com/pylast/pylast/issues/265 -""".format( - version, ".".join([str(v) for v in sys.version_info[:3]]) - ) - ) -else: - # Keep importable on Python 2 for a while to show ImportError - from urllib.parse import quote_plus as url_quote_plus - # 1 : This error does not exist STATUS_INVALID_SERVICE = 2 @@ -896,7 +867,7 @@ class _Request: keys = list(self.params.keys()) keys.sort() - cache_key = str() + cache_key = "" for key in keys: if key != "api_sig" and key != "api_key" and key != "sk": @@ -926,7 +897,7 @@ class _Request: data = [] for name in self.params.keys(): - data.append("=".join((name, url_quote_plus(_string(self.params[name]))))) + data.append("=".join((name, quote_plus(_string(self.params[name]))))) data = "&".join(data) headers = { @@ -2932,7 +2903,7 @@ def _extract_tracks(doc, network): def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a URL.""" - return url_quote_plus(url_quote_plus(_string(text))).lower() + return quote_plus(quote_plus(_string(text))).lower() def _number(string): diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 11e7ff7..416014e 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import mock import pylast import pytest From 44e47d84cec988768711618ae0ba6c9e6a86d4fb Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:17:17 +0200 Subject: [PATCH 525/768] Add support for Python 3.8 --- .pre-commit-config.yaml | 10 +++++----- .travis.yml | 6 +++--- setup.py | 1 + tox.ini | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 648afd7..f455560 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v1.24.0 + rev: v1.25.2 hooks: - id: pyupgrade args: ["--py3-plus"] - repo: https://github.com/psf/black - rev: 19.3b0 + rev: 19.10b0 hooks: - id: black language_version: python3.7 @@ -15,7 +15,7 @@ repos: types: [] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.8 + rev: 3.7.9 hooks: - id: flake8 additional_dependencies: [flake8-2020] @@ -27,11 +27,11 @@ repos: language_version: python3.7 - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.4.1 + rev: v1.4.2 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v2.4.0 hooks: - id: check-yaml diff --git a/.travis.yml b/.travis.yml index 1a5a9fb..9a13c6c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,10 @@ env: matrix: fast_finish: true include: - - python: 3.7 + - python: 3.8 env: TOXENV=lint + - python: 3.8 + env: TOXENV=py38 - python: 3.7 env: TOXENV=py37 - python: 3.6 @@ -25,8 +27,6 @@ matrix: env: TOXENV=py35 - python: pypy3 env: TOXENV=pypy3 - - python: 3.8-dev - env: TOXENV=py38dev allow_failures: - env: TOXENV=pypy3 diff --git a/setup.py b/setup.py index 37d38d2..2c30a7c 100755 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ setup( "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/tox.ini b/tox.ini index a2eeafb..b5b7dc1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py36, py35, pypy3, py38dev +envlist = py38, py37, py36, py35, pypy3 recreate = False [testenv] From d28c0ec1670520adb471e4a87bc3b7e6d4192741 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:19:57 +0200 Subject: [PATCH 526/768] Xfail those two to allow the rest to pass --- tests/test_artist.py | 2 ++ tests/test_user.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/test_artist.py b/tests/test_artist.py index 914fa6a..0e3843f 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -5,6 +5,7 @@ Integration (not unit) tests for pylast.py import unittest import pylast +import pytest from .test_pylast import TestPyLastWithLastFm @@ -281,6 +282,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert self.assertEqual(corrected_artist_name, "Guns N' Roses") + @pytest.mark.xfail def test_get_userplaycount(self): # Arrange artist = pylast.Artist("John Lennon", self.network, username=self.username) diff --git a/tests/test_user.py b/tests/test_user.py index a8b724d..f283014 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -7,6 +7,7 @@ import unittest import warnings import pylast +import pytest from .test_pylast import TestPyLastWithLastFm @@ -182,6 +183,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.assertEqual(lastfm_user, loaded_user) + @pytest.mark.xfail def test_cacheable_user(self): # Arrange lastfm_user = self.network.get_authenticated_user() From e8429b07bc87624ffcc3dc1dfe401c6c6fc2b591 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:23:16 +0200 Subject: [PATCH 527/768] Don't pin to a Python version --- .pre-commit-config.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f455560..7c3d29b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,6 @@ repos: rev: 19.10b0 hooks: - id: black - language_version: python3.7 # override until resolved: https://github.com/psf/black/issues/402 files: \.pyi?$ types: [] @@ -24,7 +23,6 @@ repos: rev: v4.3.21 hooks: - id: isort - language_version: python3.7 - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.4.2 From 6b04456c5525270a818d84a0a214f30938977545 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:32:53 +0200 Subject: [PATCH 528/768] Mock is part of stdlib in Python 3 --- README.md | 2 +- setup.py | 10 +--------- tests/unicode_test.py | 3 ++- tox.ini | 1 - 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cb8d2de..3d6f32d 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh -pip install pytest flaky mock +pip install pytest flaky pytest ``` diff --git a/setup.py b/setup.py index 2c30a7c..dc413ed 100755 --- a/setup.py +++ b/setup.py @@ -20,15 +20,7 @@ setup( author="Amr Hassan and Contributors", author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", - tests_require=[ - "coverage", - "flaky", - "mock", - "pycodestyle", - "pyflakes", - "pytest", - "pyyaml", - ], + tests_require=["coverage", "flaky", "pycodestyle", "pyflakes", "pytest", "pyyaml"], python_requires=">=3.5", classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 416014e..7efcfea 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,4 +1,5 @@ -import mock +from unittest import mock + import pylast import pytest diff --git a/tox.ini b/tox.ini index b5b7dc1..ad7fbc8 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,6 @@ setenv = deps = pyyaml pytest - mock ipdb pytest-cov pytest-random-order From 3673c0799485b517e6de5ddf5fdbe4bc18c044f3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:47:39 +0200 Subject: [PATCH 529/768] Concatenate strings, post-Black --- .pre-commit-config.yaml | 2 ++ src/pylast/__init__.py | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c3d29b..a045aa6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,7 @@ repos: rev: 19.10b0 hooks: - id: black + args: ["--target-version", "py35"] # override until resolved: https://github.com/psf/black/issues/402 files: \.pyi?$ types: [] @@ -32,4 +33,5 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.4.0 hooks: + - id: check-merge-conflict - id: check-yaml diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 18fe9a6..3e6038d 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -36,9 +36,7 @@ from xml.dom import Node, minidom from . import version __author__ = "Amr Hassan, hugovk, Mice Pápai" -__copyright__ = ( - "Copyright (C) 2008-2010 Amr Hassan, 2013-2019 hugovk, " "2017 Mice Pápai" -) +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2019 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = version.__version__ @@ -903,7 +901,7 @@ class _Request: headers = { "Content-type": "application/x-www-form-urlencoded", "Accept-Charset": "utf-8", - "User-Agent": "pylast" + "/" + __version__, + "User-Agent": "pylast/" + __version__, } (host_name, host_subdir) = self.network.ws_server From ec43b92c27a314c70b327ae0389d266ea2515acd Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:48:05 +0200 Subject: [PATCH 530/768] Use extras_require for tox deps --- setup.cfg | 3 --- setup.py | 4 +++- tox.ini | 8 +------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/setup.cfg b/setup.cfg index 3d9c412..a9810ce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,6 +4,3 @@ universal = 1 [flake8] ignore = W503 max_line_length = 88 - -[pycodestyle] -max_line_length = 88 diff --git a/setup.py b/setup.py index dc413ed..ec7d16e 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,9 @@ setup( author="Amr Hassan and Contributors", author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", - tests_require=["coverage", "flaky", "pycodestyle", "pyflakes", "pytest", "pyyaml"], + extras_require={ + "tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] + }, python_requires=">=3.5", classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tox.ini b/tox.ini index ad7fbc8..9107481 100644 --- a/tox.ini +++ b/tox.ini @@ -3,18 +3,12 @@ envlist = py38, py37, py36, py35, pypy3 recreate = False [testenv] +extras = tests setenv = PYLAST_USERNAME={env:PYLAST_USERNAME:} PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:} PYLAST_API_KEY={env:PYLAST_API_KEY:} PYLAST_API_SECRET={env:PYLAST_API_SECRET:} -deps = - pyyaml - pytest - ipdb - pytest-cov - pytest-random-order - flaky commands = pytest -v -s -W all --cov pylast --cov-report term-missing --random-order {posargs} [testenv:venv] From 432908045759672916122f2fc6c83cc6058be8fa Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 22:55:33 +0200 Subject: [PATCH 531/768] Cache pre-commit, use tox-travis to simplify config, only coverage for non-lint --- .travis.yml | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9a13c6c..7f57ad3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: python -cache: pip +cache: + pip: true + directories: + - $HOME/.cache/pre-commit env: global: @@ -18,29 +21,24 @@ matrix: - python: 3.8 env: TOXENV=lint - python: 3.8 - env: TOXENV=py38 - python: 3.7 - env: TOXENV=py37 - python: 3.6 - env: TOXENV=py36 - python: 3.5 - env: TOXENV=py35 - python: pypy3 - env: TOXENV=pypy3 - allow_failures: - - env: TOXENV=pypy3 install: -- travis_retry pip install --upgrade pip -- travis_retry pip install --upgrade tox -- travis_retry pip install --upgrade coverage +- travis_retry pip install -U pip +- travis_retry pip install -U tox-travis script: tox after_success: -- travis_retry pip install coveralls && coveralls -- travis_retry pip install codecov && codecov -- travis_retry pip install scrutinizer-ocular && ocular + - | + if [ "$TOXENV" != "lint" ]; then + travis_retry pip install -U coveralls && coveralls + travis_retry pip install -U codecov && codecov + travis_retry pip install -U scrutinizer-ocular && ocular + fi deploy: - provider: pypi From 7b5a3650744c45dbe867def59af8bb41e7e0246a Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 17 Dec 2019 23:35:53 +0200 Subject: [PATCH 532/768] Remove Scrutinizer --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 7f57ad3..86bec65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -37,7 +37,6 @@ after_success: if [ "$TOXENV" != "lint" ]; then travis_retry pip install -U coveralls && coveralls travis_retry pip install -U codecov && codecov - travis_retry pip install -U scrutinizer-ocular && ocular fi deploy: From 0fd17e6a6ea53e2dc721d706f57d7323064ff33f Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 18 Dec 2019 15:40:41 +0200 Subject: [PATCH 533/768] Add downloads badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3d6f32d..fb6282d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ pyLast [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) +[![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) [![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) From 13bccb37b6b405b2090dbfdb864aa167d5d374cf Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 20 Dec 2019 23:29:40 +0200 Subject: [PATCH 534/768] Remove outdated INSTALL file, instead: pip install pylast --- INSTALL | 4 ---- MANIFEST.in | 1 - 2 files changed, 5 deletions(-) delete mode 100644 INSTALL diff --git a/INSTALL b/INSTALL deleted file mode 100644 index f664153..0000000 --- a/INSTALL +++ /dev/null @@ -1,4 +0,0 @@ -Installation Instructions -========================= - -Execute "python setup.py install" as a super user. diff --git a/MANIFEST.in b/MANIFEST.in index 82c478f..7992ecc 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,4 @@ graft src include setup.py include README.md include COPYING -include INSTALL recursive-include tests *.py From baf808a4653f04e150c4884079ac281a3735dcf5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 22 Dec 2019 21:00:47 +0200 Subject: [PATCH 535/768] Lint with GitHub Actions --- .github/workflows/lint.yml | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..6b54bae --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v1 + + - name: pip cache + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ matrix.os }}-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ matrix.os }}-pip- + + - name: pre-commit cache + uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: + ${{ matrix.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} + restore-keys: | + ${{ matrix.os }}-pre-commit- + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox + + - name: Lint + run: tox -e lint From 846ca208954703524bd30ae70769829702512b0d Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Dec 2019 22:08:29 +0200 Subject: [PATCH 536/768] Add flake8-implicit-str-concat to pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a045aa6..ca39028 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: rev: 3.7.9 hooks: - id: flake8 - additional_dependencies: [flake8-2020] + additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.21 From ad2f9cbaa255fa0c246bc579c644831cfe9c3646 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 27 Dec 2019 23:13:58 +0200 Subject: [PATCH 537/768] Use setuptools_scm to simplify versioning during release --- MANIFEST.in | 5 ----- RELEASING.md | 42 ++++++++++++++++-------------------------- setup.py | 24 ++++++++++++------------ src/pylast/__init__.py | 4 ++-- src/pylast/version.py | 2 -- 5 files changed, 30 insertions(+), 47 deletions(-) delete mode 100755 MANIFEST.in delete mode 100644 src/pylast/version.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100755 index 7992ecc..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -graft src -include setup.py -include README.md -include COPYING -recursive-include tests *.py diff --git a/RELEASING.md b/RELEASING.md index a89d169..b890e7c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,40 +1,30 @@ # Release Checklist -* [ ] Get master to the appropriate code release state. [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Remove `.dev0` suffix from the version and update version and date in the changelog: +* [ ] Get master to the appropriate code release state. + [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for + all merges to master. + +* [ ] Tag with the version number: + ```bash -git checkout master -edit CHANGELOG.md src/pylast/version.py -``` -* [ ] Commit and tag with the version number: -```bash -git add CHANGELOG.md src/pylast/version.py -git commit -m "Release 3.0.0" -git tag -a 3.0.0 -m "Release 3.0.0" +git tag -a 3.2.0 -m "Release 3.2.0" ``` -* [ ] Push commits and tags: - ```bash -git push +* [ ] Push tag: + +```bash git push --tags ``` * [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - * Tag: Pick existing tag "3.0.0" - * Title: "Release 3.0.0" -* [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has deployed to [PyPI](https://pypi.org/project/pylast/#history) + * Tag: Pick existing tag "3.2.0" -* [ ] Check installation: `pip3 uninstall -y pylast && pip3 install -U pylast` +* [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has + deployed to [PyPI](https://pypi.org/project/pylast/#history) + +* [ ] Check installation: -* [ ] Increment version and append `.dev0`, and add Unreleased to the changelog: ```bash -git checkout master -edit CHANGELOG.md src/pylast/version.py -``` -* [ ] Commit and push: -```bash -git add CHANGELOG.md src/pylast/version.py -git commit -m "Start new release cycle" -git push +pip3 uninstall -y pylast && pip3 install -U pylast ``` diff --git a/setup.py b/setup.py index ec7d16e..3d359d9 100755 --- a/setup.py +++ b/setup.py @@ -1,25 +1,29 @@ -#!/usr/bin/env python from setuptools import find_packages, setup -version_dict = {} -with open("src/pylast/version.py") as f: - exec(f.read(), version_dict) - version = version_dict["__version__"] - - with open("README.md") as f: long_description = f.read() +def local_scheme(version): + """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) + to be able to upload to Test PyPI""" + return "" + + setup( name="pylast", description="A Python interface to Last.fm and Libre.fm", long_description=long_description, long_description_content_type="text/markdown", - version=version, author="Amr Hassan and Contributors", author_email="amr.hassan@gmail.com", url="https://github.com/pylast/pylast", + license="Apache2", + keywords=["Last.fm", "music", "scrobble", "scrobbling"], + packages=find_packages(where="src"), + package_dir={"": "src"}, + use_scm_version={"local_scheme": local_scheme}, + setup_requires=["setuptools_scm"], extras_require={ "tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] }, @@ -39,10 +43,6 @@ setup( "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", ], - keywords=["Last.fm", "music", "scrobble", "scrobbling"], - packages=find_packages(where="src"), - package_dir={"": "src"}, - license="Apache2", ) # End of file diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 3e6038d..c11143e 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -33,13 +33,13 @@ from http.client import HTTPSConnection from urllib.parse import quote_plus from xml.dom import Node, minidom -from . import version +import pkg_resources __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2019 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" -__version__ = version.__version__ +__version__ = pkg_resources.get_distribution(__name__).version # 1 : This error does not exist diff --git a/src/pylast/version.py b/src/pylast/version.py deleted file mode 100644 index aed691c..0000000 --- a/src/pylast/version.py +++ /dev/null @@ -1,2 +0,0 @@ -# Master version for pylast -__version__ = "3.2.0.dev0" From f9ced38512df0d07e3229104d584505b130e6fd4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 3 Jan 2020 13:23:01 +0200 Subject: [PATCH 538/768] Add known_third_party to isort and pre-commit --- .pre-commit-config.yaml | 7 ++++++- setup.cfg | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca39028..a401b22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,13 +20,18 @@ repos: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] + - repo: https://github.com/asottile/seed-isort-config + rev: v1.9.4 + hooks: + - id: seed-isort-config + - repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.21 hooks: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.4.2 + rev: v1.4.3 hooks: - id: python-check-blanket-noqa diff --git a/setup.cfg b/setup.cfg index a9810ce..69e5b6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,6 @@ universal = 1 [flake8] ignore = W503 max_line_length = 88 + +[tool:isort] +known_third_party = flaky,pkg_resources,pylast,pytest,setuptools From ab4fa70fe97cf201f6e187919a8b382f27e16e99 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 3 Jan 2020 13:26:15 +0200 Subject: [PATCH 539/768] Update changelog for pylast 3.2.0 --- CHANGELOG.md | 43 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf959b..905d270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -### TODO +## [3.2.0] - 2020-01-03 +### Added + +* Support for Python 3.8 +* Store album art URLs when you call `GetTopAlbums` ([#307]) +* Retry paging through results on exception ([#297]) +* More error status codes from https://last.fm/api/errorcodes ([#297]) + +### Changed + +* Respect `get_recent_tracks`' limit when there's a now playing track ([#310]) +* Move installable code to `src/` ([#301]) +* Update `get_weekly_artist_charts` docstring: only for `User` ([#311]) +* Remove Python 2 warnings, `python_requires` should be enough ([#312]) +* Use setuptools_scm to simplify versioning during release ([#316]) +* Various lint and test updates + +### Deprecated + +* Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer + available. Last.fm returns a "Deprecated - This type of request is no longer + supported" error when calling it. A future version of pylast will remove its + `User.get_artist_tracks` altogether. ([#305]) + +* `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. + Use `STATUS_OPERATION_FAILED` instead. ## [3.1.0] - 2019-03-07 ### Added @@ -36,12 +60,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) -[Unreleased]: https://github.com/pylast/pylast/compare/v3.1.0...HEAD +[Unreleased]: https://github.com/pylast/pylast/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/pylast/pylast/compare/v3.1.0...3.2.0 [3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 -[#298]: https://github.com/pylast/pylast/issues/298 -[#290]: https://github.com/pylast/pylast/pull/290 [#265]: https://github.com/pylast/pylast/issues/265 [#273]: https://github.com/pylast/pylast/issues/273 [#282]: https://github.com/pylast/pylast/pull/282 +[#290]: https://github.com/pylast/pylast/pull/290 +[#297]: https://github.com/pylast/pylast/issues/297 +[#298]: https://github.com/pylast/pylast/issues/298 +[#301]: https://github.com/pylast/pylast/issues/301 +[#305]: https://github.com/pylast/pylast/issues/305 +[#307]: https://github.com/pylast/pylast/issues/307 +[#310]: https://github.com/pylast/pylast/issues/310 +[#311]: https://github.com/pylast/pylast/issues/311 +[#312]: https://github.com/pylast/pylast/issues/312 +[#316]: https://github.com/pylast/pylast/issues/316 From 662e3bbdddf7416cf7814845095d22a6c9b13128 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 3 Jan 2020 13:26:27 +0200 Subject: [PATCH 540/768] Update copyright year --- src/pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c11143e..1154204 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -3,7 +3,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2019 hugovk +# Copyright 2013-2020 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ from xml.dom import Node, minidom import pkg_resources __author__ = "Amr Hassan, hugovk, Mice Pápai" -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2019 hugovk, 2017 Mice Pápai" +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2020 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = pkg_resources.get_distribution(__name__).version From 27228e785f161f33514be22a780735e324817ffe Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 3 Jan 2020 13:43:16 +0200 Subject: [PATCH 541/768] Update instructions: include changelog in release --- CHANGELOG.md | 10 ++++++++++ RELEASING.md | 2 ++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905d270..dcfbd0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +TODO +### Changed +TODO +### Deprecated +TODO +### Removed +TODO + ## [3.2.0] - 2020-01-03 ### Added diff --git a/RELEASING.md b/RELEASING.md index b890e7c..23692c6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,6 +20,8 @@ git push --tags * Tag: Pick existing tag "3.2.0" + * Description: Copy from `CHANGELOG.md` + * [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has deployed to [PyPI](https://pypi.org/project/pylast/#history) From cd18581fe27af6e6a37df52dcf83793c971c8454 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 4 Feb 2020 11:57:40 +0200 Subject: [PATCH 542/768] Only Python 3 is supported: don't create universal wheel --- .github/workflows/lint.yml | 9 ++++----- .pre-commit-config.yaml | 8 ++++---- setup.cfg | 3 --- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6b54bae..0d6030e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,18 +16,17 @@ jobs: uses: actions/cache@v1 with: path: ~/.cache/pip - key: ${{ matrix.os }}-pip-${{ hashFiles('**/setup.py') }} + key: lint-pip-${{ hashFiles('**/setup.py') }} restore-keys: | - ${{ matrix.os }}-pip- + lint-pip- - name: pre-commit cache uses: actions/cache@v1 with: path: ~/.cache/pre-commit - key: - ${{ matrix.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} + key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | - ${{ matrix.os }}-pre-commit- + lint-pre-commit- - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a401b22..9ffb50b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v1.25.2 + rev: v1.26.2 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -25,13 +25,13 @@ repos: hooks: - id: seed-isort-config - - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + - repo: https://github.com/timothycrosley/isort + rev: 4.3.21 hooks: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.4.3 + rev: v1.4.4 hooks: - id: python-check-blanket-noqa diff --git a/setup.cfg b/setup.cfg index 69e5b6b..bf5350d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,3 @@ -[bdist_wheel] -universal = 1 - [flake8] ignore = W503 max_line_length = 88 From 21f127f88b3bc6b24335c43845372df382f93916 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 15 Feb 2020 18:19:59 +0200 Subject: [PATCH 543/768] Fix regression calling get_recent_tracks with limit=None --- .pre-commit-config.yaml | 4 ++-- src/pylast/__init__.py | 12 +++++++++--- tests/test_user.py | 29 +++++++++++++++++++++++------ tox.ini | 1 - 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ffb50b..64706ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,12 +31,12 @@ repos: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.4.4 + rev: v1.5.1 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v2.5.0 hooks: - id: check-merge-conflict - id: check-yaml diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 1154204..50c1206 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2358,7 +2358,11 @@ class User(_BaseObject, _Chartable): seq = [] for track in _collect_nodes( - limit + 1, self, self.ws_prefix + ".getRecentTracks", cacheable, params + limit + 1 if limit else None, + self, + self.ws_prefix + ".getRecentTracks", + cacheable, + params, ): if track.hasAttribute("nowplaying"): @@ -2374,8 +2378,10 @@ class User(_BaseObject, _Chartable): PlayedTrack(Track(artist, title, self.network), album, date, timestamp) ) - # Slice, in case we didn't remove a now playing track - return seq[:limit] + if limit: + # Slice, in case we didn't remove a now playing track + seq = seq[:limit] + return seq def get_country(self): """Returns the name of the country of the user.""" diff --git a/tests/test_user.py b/tests/test_user.py index f283014..4d8ac83 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +import calendar +import datetime as dt import os import unittest import warnings @@ -354,12 +356,8 @@ class TestPyLastUser(TestPyLastWithLastFm): def test_get_recent_tracks_from_to(self): # Arrange lastfm_user = self.network.get_user("RJ") - - from datetime import datetime - - start = datetime(2011, 7, 21, 15, 10) - end = datetime(2011, 7, 21, 15, 15) - import calendar + start = dt.datetime(2011, 7, 21, 15, 10) + end = dt.datetime(2011, 7, 21, 15, 15) utc_start = calendar.timegm(start.utctimetuple()) utc_end = calendar.timegm(end.utctimetuple()) @@ -372,6 +370,25 @@ class TestPyLastUser(TestPyLastWithLastFm): self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + def test_get_recent_tracks_limit_none(self): + # Arrange + lastfm_user = self.network.get_user("bbc6music") + start = dt.datetime(2020, 2, 15, 15, 00) + end = dt.datetime(2020, 2, 15, 15, 40) + + utc_start = calendar.timegm(start.utctimetuple()) + utc_end = calendar.timegm(end.utctimetuple()) + + # Act + tracks = lastfm_user.get_recent_tracks( + time_from=utc_start, time_to=utc_end, limit=None + ) + + # Assert + self.assertEqual(len(tracks), 11) + self.assertEqual(str(tracks[0].track.artist), "Seun Kuti & Egypt 80") + self.assertEqual(str(tracks[0].track.title), "Struggles Sounds") + def test_get_playcount(self): # Arrange user = self.network.get_user("RJ") diff --git a/tox.ini b/tox.ini index 9107481..f6e43d7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = py38, py37, py36, py35, pypy3 -recreate = False [testenv] extras = tests From e22ad6b464b432245a77834d0c879799e99f3e79 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 15 Feb 2020 18:34:20 +0200 Subject: [PATCH 544/768] Link to CHANGELOG.md [CI skip] --- RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 23692c6..0b97c7b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -20,7 +20,7 @@ git push --tags * Tag: Pick existing tag "3.2.0" - * Description: Copy from `CHANGELOG.md` + * Description: Copy from [`CHANGELOG.md`](CHANGELOG.md) * [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has deployed to [PyPI](https://pypi.org/project/pylast/#history) From 4e98e7af4c89912b254243115f2bd502e62498a6 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 27 Feb 2020 22:23:16 +0200 Subject: [PATCH 545/768] Use release drafter to draft releases --- .github/ISSUE_TEMPLATE.md | 8 ++- .github/labels.yml | 96 +++++++++++++++++++++++++++ .github/release-drafter.yml | 28 ++++++++ .github/workflows/labels.yml | 15 +++++ .github/workflows/release-drafter.yml | 17 +++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 .github/labels.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/labels.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 97aaea8..1480fa1 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -4,11 +4,15 @@ ### What actually happened? -### What versions of OS, Python and pylast are you using? +### What versions are you using? + +* OS: +* Python: +* pylast: Please include **code** that reproduces the issue. -The [best reproductions](https://stackoverflow.com/help/mcve) are [self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) with minimal dependencies. +The [best reproductions](https://stackoverflow.com/help/minimal-reproducible-example) are [self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) with minimal dependencies. ```python code goes here diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..e133e75 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,96 @@ +# Default GitHub labels +- color: d73a4a + description: "Something isn't working" + name: bug +- color: cfd3d7 + description: "This issue or pull request already exists" + name: duplicate +- color: a2eeef + description: "New feature or request" + name: enhancement +- color: 7057ff + description: "Good for newcomers" + name: good first issue +- color: 008672 + description: "Extra attention is needed" + name: help wanted +- color: e4e669 + description: "This doesn't seem right" + name: invalid +- color: d876e3 + description: "Further information is requested" + name: question +- color: ffffff + description: "This will not be worked on" + name: wontfix + +# Keep a Changelog labels +# https://keepachangelog.com/en/1.0.0/ +- color: 0e8a16 + description: "For new features" + name: "changelog: Added" +- color: af99e5 + description: "For changes in existing functionality" + name: "changelog: Changed" +- color: FFA500 + description: "For soon-to-be removed features" + name: "changelog: Deprecated" +- color: 00A800 + description: "For any bug fixes" + name: "changelog: Fixed" +- color: ff0000 + description: "For now removed features" + name: "changelog: Removed" +- color: 045aa0 + description: "In case of vulnerabilities" + name: "changelog: Security" +- color: fbca04 + description: "Exclude PR from release draft" + name: "changelog: skip" + +# Other labels +- color: e11d21 + description: "" + name: Last.fm bug +- color: FFFFFF + description: "" + name: Milestone-0.3 +- color: FFFFFF + description: "" + name: Performance +- color: FFFFFF + description: "" + name: Priority-High +- color: FFFFFF + description: "" + name: Priority-Low +- color: FFFFFF + description: "" + name: Priority-Medium +- color: FFFFFF + description: "" + name: Type-Other +- color: FFFFFF + description: "" + name: Type-Patch +- color: FFFFFF + description: "" + name: Usability +- color: 64c1c0 + description: "" + name: backwards incompatible +- color: fef2c0 + description: "" + name: build +- color: e99695 + description: Feature that will be removed in the future + name: deprecation +- color: FFFFFF + description: "" + name: imported +- color: b60205 + description: Removal of a feature, usually done in major releases + name: removal +- color: fef2c0 + description: "" + name: test diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..402bfbf --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: "$NEXT_PATCH_VERSION" +tag-template: "$NEXT_PATCH_VERSION" + +categories: + - title: "Added" + labels: + - "changelog: Added" + - "enhancement" + - title: "Changed" + label: "changelog: Changed" + - title: "Deprecated" + label: "changelog: Deprecated" + - title: "Removed" + label: "changelog: Removed" + - title: "Fixed" + labels: + - "changelog: Fixed" + - "bug" + - title: "Security" + label: "changelog: Security" + +exclude-labels: + - "changelog: skip" + +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml new file mode 100644 index 0000000..2303846 --- /dev/null +++ b/.github/workflows/labels.yml @@ -0,0 +1,15 @@ +name: Sync labels +on: + push: + branches: + - master + paths: + - .github/labels.yml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: micnncim/action-label-syncer@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..f1d92f9 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,17 @@ +name: Release drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - master + +jobs: + update_release_draft: + if: github.repository == 'pylast/pylast' + runs-on: ubuntu-latest + steps: + # Drafts your next release notes as pull requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From af036ec5d5ab9bcc01dec5fb7ad71c82345d6038 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 27 Feb 2020 22:29:32 +0200 Subject: [PATCH 546/768] pre-commit autoupdate --- .pre-commit-config.yaml | 8 ++++---- tests/test_pylast.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ffb50b..664a22a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v1.26.2 + rev: v2.1.0 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -21,7 +21,7 @@ repos: additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/asottile/seed-isort-config - rev: v1.9.4 + rev: v2.1.0 hooks: - id: seed-isort-config @@ -31,12 +31,12 @@ repos: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.4.4 + rev: v1.5.1 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v2.5.0 hooks: - id: check-merge-conflict - id: check-yaml diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 75fb379..c5a10b4 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -19,7 +19,7 @@ def load_secrets(): if os.path.isfile(secrets_file): import yaml # pip install pyyaml - with open(secrets_file, "r") as f: # see example_test_pylast.yaml + with open(secrets_file) as f: # see example_test_pylast.yaml doc = yaml.load(f) else: doc = {} From a0255a61e1c85c3f5e257b3ccfb7e37913e69761 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Mar 2020 22:16:50 +0200 Subject: [PATCH 547/768] Add a version number to cache file to invalidate it --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d6030e..e543be6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,7 +24,7 @@ jobs: uses: actions/cache@v1 with: path: ~/.cache/pre-commit - key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} + key: lint-pre-commit-v1-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | lint-pre-commit- From c8e3162b9dc57e4cc2190639e775a3bca34ff526 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Mar 2020 22:18:39 +0200 Subject: [PATCH 548/768] Add a version number to cache file to invalidate it --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e543be6..0d8ce71 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,7 +26,7 @@ jobs: path: ~/.cache/pre-commit key: lint-pre-commit-v1-${{ hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | - lint-pre-commit- + lint-pre-commit-v1- - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 From 7005dbc13fdf59e60cff034aa38f16fa2af4c2e9 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Mar 2020 22:22:33 +0200 Subject: [PATCH 549/768] Bump to test with a new cache --- setup.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/setup.py b/setup.py index 3d359d9..7238518 100755 --- a/setup.py +++ b/setup.py @@ -44,5 +44,3 @@ setup( "Programming Language :: Python :: Implementation :: PyPy", ], ) - -# End of file From f806a1f36af895663fefaf4b43c4a9c96c07660b Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 4 Mar 2020 22:33:22 +0200 Subject: [PATCH 550/768] Fix DeprecationWarning: Please use assertRegex instead --- tests/test_user.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_user.py b/tests/test_user.py index 4d8ac83..e371529 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -277,9 +277,7 @@ class TestPyLastUser(TestPyLastWithLastFm): top_album = albums[0].item self.assertTrue(len(top_album.info["image"])) - self.assertRegexpMatches( - top_album.info["image"][pylast.SIZE_LARGE], r"^http.+$" - ) + self.assertRegex(top_album.info["image"][pylast.SIZE_LARGE], r"^http.+$") def test_user_tagged_artists(self): # Arrange From dfb694f2a6efe441146f8a852d44f4601ee614d0 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 5 Mar 2020 00:46:04 +0200 Subject: [PATCH 551/768] Add 3.2.1 --- CHANGELOG.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcfbd0d..2a00351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -### Added -TODO -### Changed -TODO -### Deprecated -TODO -### Removed -TODO +## [3.2.1] - 2020-03-05 +### Fixed + +* Only Python 3 is supported: don't create universal wheel (#318) @hugovk +* Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk +* Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk ## [3.2.0] - 2020-01-03 ### Added @@ -70,7 +67,7 @@ TODO * Support for Python 2.7 ([#265]) -[Unreleased]: https://github.com/pylast/pylast/compare/v3.2.0...HEAD +[3.2.1]: https://github.com/pylast/pylast/compare/v3.2.0...3.2.1 [3.2.0]: https://github.com/pylast/pylast/compare/v3.1.0...3.2.0 [3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 From fa0189d9bcab47d9816737216a8e479d22f5297b Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 5 Mar 2020 00:52:13 +0200 Subject: [PATCH 552/768] Update release checklist for Release Drafter --- RELEASING.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 0b97c7b..068ef1e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,23 +4,13 @@ [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Tag with the version number: +* [ ] Edit release draft, adjust text if needed: https://github.com/pylast/pylast/releases -```bash -git tag -a 3.2.0 -m "Release 3.2.0" -``` +* [ ] Check next tag is correct, amend if needed -* [ ] Push tag: +* [ ] Copy text into [`CHANGELOG.md`](CHANGELOG.md) -```bash -git push --tags -``` - -* [ ] Create new GitHub release: https://github.com/pylast/pylast/releases/new - - * Tag: Pick existing tag "3.2.0" - - * Description: Copy from [`CHANGELOG.md`](CHANGELOG.md) +* [ ] Publish release * [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has deployed to [PyPI](https://pypi.org/project/pylast/#history) From 5391e0d23358d3ec2553a766c204801049d0bf97 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 13 Mar 2020 17:15:02 +0200 Subject: [PATCH 553/768] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ac20214 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: hugovk From b4092695a0fc343cf6123832c332c7a75407ddbf Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 20 Mar 2020 19:05:55 +0200 Subject: [PATCH 554/768] Add Zenodo DOI badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fb6282d..806c718 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ pyLast [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) [![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) +[![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). From a040f42f9570c1a5edb77cdebded35c019349edf Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 23 Mar 2020 14:28:19 +0200 Subject: [PATCH 555/768] Use v2 of actions/checkout --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d8ce71..03896a5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: python-version: [3.8] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: pip cache uses: actions/cache@v1 From 6a28ba076fafec2105ca225a0e2d88a5c0a48221 Mon Sep 17 00:00:00 2001 From: spiritualized Date: Tue, 28 Apr 2020 04:22:31 -0400 Subject: [PATCH 556/768] Improve handling of error responses from the API --- src/pylast/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 50c1206..8495c58 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -932,7 +932,15 @@ class _Request: raise NetworkError(self.network, e) try: - response_text = _unicode(conn.getresponse().read()) + response = conn.getresponse() + if response.status in [500, 502, 503, 504]: + raise WSError( + self.network, + response.status, + "Connection to the API failed with HTTP code " + + str(response.status), + ) + response_text = _unicode(response.read()) except Exception as e: raise MalformedResponseError(self.network, e) From a37365450054e206d5e588b47e5d8c4d102fbdcf Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 16 Apr 2020 09:31:12 +0300 Subject: [PATCH 557/768] Return None if bio section is empty --- src/pylast/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 8495c58..5ddab60 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1142,7 +1142,12 @@ class _BaseObject: def _extract_cdata_from_request(self, method_name, tag_name, params): doc = self._request(method_name, True, params) - return doc.getElementsByTagName(tag_name)[0].firstChild.wholeText.strip() + first_child = doc.getElementsByTagName(tag_name)[0].firstChild + + if first_child is None: + return None + + return first_child.wholeText.strip() def _get_things(self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" From 26f21731601c31db8c637bffac9677eeaa291671 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 16 Apr 2020 09:33:08 +0300 Subject: [PATCH 558/768] pre-commit autoupdate --- .pre-commit-config.yaml | 6 +++--- RELEASING.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 664a22a..1802b34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.1.0 + rev: v2.4.1 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -15,13 +15,13 @@ repos: types: [] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.7.9 + rev: 3.8.0a2 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/asottile/seed-isort-config - rev: v2.1.0 + rev: v2.1.1 hooks: - id: seed-isort-config diff --git a/RELEASING.md b/RELEASING.md index 068ef1e..6842e8f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,8 @@ [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for all merges to master. -* [ ] Edit release draft, adjust text if needed: https://github.com/pylast/pylast/releases +* [ ] Edit release draft, adjust text if needed: + https://github.com/pylast/pylast/releases * [ ] Check next tag is correct, amend if needed From 925cae20311c7e5f975e9bea9961e977a5391402 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 16 Apr 2020 09:41:13 +0300 Subject: [PATCH 559/768] Add test for artist with no bio content --- tests/test_artist.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_artist.py b/tests/test_artist.py index 0e3843f..8837261 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -52,6 +52,17 @@ class TestPyLastArtist(TestPyLastWithLastFm): self.assertIsNotNone(bio) self.assertGreaterEqual(len(bio), 1) + def test_bio_content_none(self): + # Arrange + # An artist with no biography, with "" in the API XML + artist = pylast.Artist("Mr Sizef + Unquote", self.network) + + # Act + bio = artist.get_bio_content() + + # Assert + self.assertIsNone(bio) + def test_bio_summary(self): # Arrange artist = pylast.Artist("Test Artist", self.network) From 7689a1e95aa3e8005601ebd921c264776f992fa8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 1 Jun 2020 14:52:29 +0300 Subject: [PATCH 560/768] User.get_now_playing: Add album and cover image to info --- src/pylast/__init__.py | 3 ++- tests/test_network.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 5ddab60..4befdb7 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2338,8 +2338,9 @@ class User(_BaseObject, _Chartable): artist = _extract(e, "artist") title = _extract(e, "name") + info = {"album": _extract(e, "album"), "image": _extract_all(e, "image")} - return Track(artist, title, self.network, self.name) + return Track(artist, title, self.network, self.name, info=info) def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None): """ diff --git a/tests/test_network.py b/tests/test_network.py index 36188ec..95587e9 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -47,6 +47,10 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assertIsNotNone(current_track) self.assertEqual(str(current_track.title).lower(), "test title") self.assertEqual(str(current_track.artist).lower(), "test artist") + self.assertEqual(current_track.info["album"], "Test Album") + + self.assertTrue(len(current_track.info["image"])) + self.assertRegex(current_track.info["image"][pylast.SIZE_LARGE], r"^http.+$") def test_enable_rate_limiting(self): # Arrange From 8437316d3d89dd7631a5892eeb19d7ca2b600808 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 1 Jun 2020 14:57:33 +0300 Subject: [PATCH 561/768] pre-commit autoupdate --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1802b34..8461ec4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.4.1 + rev: v2.4.4 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -15,7 +15,7 @@ repos: types: [] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.0a2 + rev: 3.8.2 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -36,7 +36,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.5.0 + rev: v3.1.0 hooks: - id: check-merge-conflict - id: check-yaml From 1160ee151327bc71d028e10de9c5bf761a7b6237 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 1 Jun 2020 15:20:36 +0300 Subject: [PATCH 562/768] GHA: Simplify and update lint.yml --- .github/workflows/lint.yml | 39 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 03896a5..13f3f43 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,39 +4,34 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] + runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 - - name: pip cache - uses: actions/cache@v1 + - name: Cache + uses: actions/cache@v2 with: - path: ~/.cache/pip - key: lint-pip-${{ hashFiles('**/setup.py') }} + path: | + ~/.cache/pip + ~/.cache/pre-commit + key: + lint-v2-${{ hashFiles('**/setup.py') }}-${{ + hashFiles('**/.pre-commit-config.yaml') }} restore-keys: | - lint-pip- + lint-v2- - - name: pre-commit cache - uses: actions/cache@v1 + - name: Set up Python + uses: actions/setup-python@v2 with: - path: ~/.cache/pre-commit - key: lint-pre-commit-v1-${{ hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-pre-commit-v1- - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} + python-version: 3.8 - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install --upgrade tox + python -m pip install -U pip + python -m pip install -U tox - name: Lint run: tox -e lint + env: + PRE_COMMIT_COLOR: always From aae4bb3693e3331a526eb4eb93f0f5c01e26dc94 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 1 Jun 2020 15:50:27 +0300 Subject: [PATCH 563/768] Replace unittest with pytest --- tests/test_album.py | 28 ++++----- tests/test_artist.py | 78 +++++++++++------------- tests/test_country.py | 18 ++---- tests/test_library.py | 8 +-- tests/test_librefm.py | 8 +-- tests/test_network.py | 139 ++++++++++++++++++++---------------------- tests/test_pylast.py | 84 ++++++++++++------------- tests/test_tag.py | 20 +++--- tests/test_track.py | 74 +++++++++++----------- tests/test_user.py | 101 +++++++++++++++--------------- 10 files changed, 253 insertions(+), 305 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index 878e4e1..8a213b5 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast from .test_pylast import TestPyLastWithLastFm @@ -18,8 +16,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm): tags = album.get_top_tags(limit=1) # Assert - self.assertGreater(len(tags), 0) - self.assertIsInstance(tags[0], pylast.TopItem) + assert len(tags) > 0 + assert isinstance(tags[0], pylast.TopItem) def test_album_is_hashable(self): # Arrange @@ -37,7 +35,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): track = lastfm_user.get_recent_tracks(limit=2)[0] # Assert - self.assertTrue(hasattr(track, "album")) + assert hasattr(track, "album") def test_album_wiki_content(self): # Arrange @@ -47,8 +45,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm): wiki = album.get_wiki_content() # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) + assert wiki is not None + assert len(wiki) >= 1 def test_album_wiki_published_date(self): # Arrange @@ -58,8 +56,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm): wiki = album.get_wiki_published_date() # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) + assert wiki is not None + assert len(wiki) >= 1 def test_album_wiki_summary(self): # Arrange @@ -69,8 +67,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm): wiki = album.get_wiki_summary() # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) + assert wiki is not None + assert len(wiki) >= 1 def test_album_eq_none_is_false(self): # Arrange @@ -78,7 +76,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert - self.assertNotEqual(album1, album2) + assert album1 != album2 def test_album_ne_none_is_true(self): # Arrange @@ -86,7 +84,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): album2 = pylast.Album("Test Artist", "Test Album", self.network) # Act / Assert - self.assertNotEqual(album1, album2) + assert album1 != album2 def test_get_cover_image(self): # Arrange @@ -98,7 +96,3 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert self.assert_startswith(image, "https://") self.assert_endswith(image, ".png") - - -if __name__ == "__main__": - unittest.main(failfast=True) diff --git a/tests/test_artist.py b/tests/test_artist.py index 8837261..aa3e007 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast import pytest @@ -19,13 +17,13 @@ class TestPyLastArtist(TestPyLastWithLastFm): representation = repr(artist) # Assert - self.assertTrue(representation.startswith("pylast.Artist('Test Artist',")) + assert representation.startswith("pylast.Artist('Test Artist',") def test_artist_is_hashable(self): # Arrange test_artist = self.network.get_artist("Test Artist") artist = test_artist.get_similar(limit=2)[0].item - self.assertIsInstance(artist, pylast.Artist) + assert isinstance(artist, pylast.Artist) # Act/Assert self.helper_is_thing_hashable(artist) @@ -38,8 +36,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): bio = artist.get_bio_published_date() # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) + assert bio is not None + assert len(bio) >= 1 def test_bio_content(self): # Arrange @@ -49,8 +47,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): bio = artist.get_bio_content(language="en") # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) + assert bio is not None + assert len(bio) >= 1 def test_bio_content_none(self): # Arrange @@ -61,7 +59,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): bio = artist.get_bio_content() # Assert - self.assertIsNone(bio) + assert bio is None def test_bio_summary(self): # Arrange @@ -71,8 +69,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): bio = artist.get_bio_summary(language="en") # Assert - self.assertIsNotNone(bio) - self.assertGreaterEqual(len(bio), 1) + assert bio is not None + assert len(bio) >= 1 def test_artist_top_tracks(self): # Arrange @@ -106,7 +104,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): things = artist.get_top_albums(limit=limit) # Assert - self.assertEqual(len(things), 1) + assert len(things) == 1 def test_artist_top_albums_limit_50(self): # Arrange @@ -118,7 +116,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): things = artist.get_top_albums(limit=limit) # Assert - self.assertEqual(len(things), 50) + assert len(things) == 50 def test_artist_top_albums_limit_100(self): # Arrange @@ -130,7 +128,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): things = artist.get_top_albums(limit=limit) # Assert - self.assertEqual(len(things), 100) + assert len(things) == 100 def test_artist_listener_count(self): # Arrange @@ -140,8 +138,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): count = artist.get_listener_count() # Assert - self.assertIsInstance(count, int) - self.assertGreater(count, 0) + assert isinstance(count, int) + assert count > 0 def test_tag_artist(self): # Arrange @@ -153,13 +151,13 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags = artist.get_tags() - self.assertGreater(len(tags), 0) + assert len(tags) > 0 found = False for tag in tags: if tag.name == "testing": found = True break - self.assertTrue(found) + assert found def test_remove_tag_of_type_text(self): # Arrange @@ -177,7 +175,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): if tag.name == "testing": found = True break - self.assertFalse(found) + assert not found def test_remove_tag_of_type_tag(self): # Arrange @@ -195,7 +193,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): if tag.name == "testing": found = True break - self.assertFalse(found) + assert not found def test_remove_tags(self): # Arrange @@ -210,15 +208,15 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags_after = artist.get_tags() - self.assertEqual(len(tags_after), len(tags_before) - 2) + assert len(tags_after) == len(tags_before) - 2 found1, found2 = False, False for tag in tags_after: if tag.name == "removetag1": found1 = True elif tag.name == "removetag2": found2 = True - self.assertFalse(found1) - self.assertFalse(found2) + assert not found1 + assert not found2 def test_set_tags(self): # Arrange @@ -233,16 +231,16 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags_after = artist.get_tags() - self.assertNotEqual(tags_before, tags_after) - self.assertEqual(len(tags_after), 2) + assert tags_before != tags_after + assert len(tags_after) == 2 found1, found2 = False, False for tag in tags_after: if tag.name == "settag1": found1 = True elif tag.name == "settag2": found2 = True - self.assertTrue(found1) - self.assertTrue(found2) + assert found1 + assert found2 def test_artists(self): # Arrange @@ -259,13 +257,13 @@ class TestPyLastArtist(TestPyLastWithLastFm): name_cap = artist1.get_name(properly_capitalized=True) # Assert - self.assertIn("https", image) - self.assertGreater(playcount, 1) - self.assertNotEqual(artist1, artist2) - self.assertEqual(name.lower(), name_cap.lower()) - self.assertEqual(url, "https://www.last.fm/music/radiohead") - self.assertEqual(mbid, "a74b1b7f-71a5-4011-9441-d0b5e4122711") - self.assertIsInstance(streamable, bool) + assert "https" in image + assert playcount > 1 + assert artist1 != artist2 + assert name.lower() == name_cap.lower() + assert url == "https://www.last.fm/music/radiohead" + assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711" + assert isinstance(streamable, bool) def test_artist_eq_none_is_false(self): # Arrange @@ -273,7 +271,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert - self.assertNotEqual(artist1, artist2) + assert artist1 != artist2 def test_artist_ne_none_is_true(self): # Arrange @@ -281,7 +279,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist2 = pylast.Artist("Test Artist", self.network) # Act / Assert - self.assertNotEqual(artist1, artist2) + assert artist1 != artist2 def test_artist_get_correction(self): # Arrange @@ -291,7 +289,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): corrected_artist_name = artist.get_correction() # Assert - self.assertEqual(corrected_artist_name, "Guns N' Roses") + assert corrected_artist_name == "Guns N' Roses" @pytest.mark.xfail def test_get_userplaycount(self): @@ -302,8 +300,4 @@ class TestPyLastArtist(TestPyLastWithLastFm): playcount = artist.get_userplaycount() # Assert - self.assertGreaterEqual(playcount, 0) - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert playcount >= 0 diff --git a/tests/test_country.py b/tests/test_country.py index 1eede36..4561d82 100755 --- a/tests/test_country.py +++ b/tests/test_country.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast from .test_pylast import TestPyLastWithLastFm @@ -28,13 +26,9 @@ class TestPyLastCountry(TestPyLastWithLastFm): url = country1.get_url() # Assert - self.assertIn("Italy", rep) - self.assertIn("pylast.Country", rep) - self.assertEqual(text, "Italy") - self.assertEqual(country1, country1) - self.assertNotEqual(country1, country2) - self.assertEqual(url, "https://www.last.fm/place/italy") - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert "Italy" in rep + assert "pylast.Country" in rep + assert text == "Italy" + assert country1 == country1 + assert country1 != country2 + assert url == "https://www.last.fm/place/italy" diff --git a/tests/test_library.py b/tests/test_library.py index 3b3de36..dea876d 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast from .test_pylast import TestPyLastWithLastFm @@ -53,8 +51,4 @@ class TestPyLastLibrary(TestPyLastWithLastFm): library_user = library.get_user() # Assert - self.assertEqual(library_user, user_to_get) - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert library_user == user_to_get diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 7f8d54b..cb8ddcc 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast from flaky import flaky @@ -26,7 +24,7 @@ class TestPyLastWithLibreFm(PyLastTestCase): name = artist.get_name() # Assert - self.assertEqual(name, "Radiohead") + assert name == "Radiohead" def test_repr(self): # Arrange @@ -40,7 +38,3 @@ class TestPyLastWithLibreFm(PyLastTestCase): # Assert self.assert_startswith(representation, "pylast.LibreFMNetwork(") - - -if __name__ == "__main__": - unittest.main(failfast=True) diff --git a/tests/test_network.py b/tests/test_network.py index 95587e9..85362a3 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,16 +2,17 @@ """ Integration (not unit) tests for pylast.py """ +import re import time -import unittest import pylast +import pytest from .test_pylast import PY37, TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): - @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") + @pytest.mark.skipif(not PY37, reason="Only run on Python 3.7 to avoid collisions") def test_scrobble(self): # Arrange artist = "test artist" @@ -26,8 +27,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert # limit=2 to ignore now-playing: last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] - self.assertEqual(str(last_scrobble.track.artist).lower(), artist) - self.assertEqual(str(last_scrobble.track.title).lower(), title) + assert str(last_scrobble.track.artist).lower() == artist + assert str(last_scrobble.track.title).lower() == title def test_update_now_playing(self): # Arrange @@ -44,17 +45,17 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert current_track = lastfm_user.get_now_playing() - self.assertIsNotNone(current_track) - self.assertEqual(str(current_track.title).lower(), "test title") - self.assertEqual(str(current_track.artist).lower(), "test artist") - self.assertEqual(current_track.info["album"], "Test Album") + assert current_track is not None + assert str(current_track.title).lower() == "test title" + assert str(current_track.artist).lower() == "test artist" + assert current_track.info["album"] == "Test Album" - self.assertTrue(len(current_track.info["image"])) - self.assertRegex(current_track.info["image"][pylast.SIZE_LARGE], r"^http.+$") + assert len(current_track.info["image"]) + assert re.search(r"^http.+$", current_track.info["image"][pylast.SIZE_LARGE]) def test_enable_rate_limiting(self): # Arrange - self.assertFalse(self.network.is_rate_limited()) + assert not self.network.is_rate_limited() # Act self.network.enable_rate_limit() @@ -66,13 +67,13 @@ class TestPyLastNetwork(TestPyLastWithLastFm): now = time.time() # Assert - self.assertTrue(self.network.is_rate_limited()) - self.assertGreaterEqual(now - then, 0.2) + assert self.network.is_rate_limited() + assert now - then >= 0.2 def test_disable_rate_limiting(self): # Arrange self.network.enable_rate_limit() - self.assertTrue(self.network.is_rate_limited()) + assert self.network.is_rate_limited() # Act self.network.disable_rate_limit() @@ -82,14 +83,14 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.network.get_top_artists() # Assert - self.assertFalse(self.network.is_rate_limited()) + assert not self.network.is_rate_limited() def test_lastfm_network_name(self): # Act name = str(self.network) # Assert - self.assertEqual(name, "Last.fm Network") + assert name == "Last.fm Network" def test_geo_get_top_artists(self): # Arrange @@ -97,9 +98,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm): artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1) # Assert - self.assertEqual(len(artists), 1) - self.assertIsInstance(artists[0], pylast.TopItem) - self.assertIsInstance(artists[0].item, pylast.Artist) + assert len(artists) == 1 + assert isinstance(artists[0], pylast.TopItem) + assert isinstance(artists[0].item, pylast.Artist) def test_geo_get_top_tracks(self): # Arrange @@ -109,9 +110,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm): ) # Assert - self.assertEqual(len(tracks), 1) - self.assertIsInstance(tracks[0], pylast.TopItem) - self.assertIsInstance(tracks[0].item, pylast.Track) + assert len(tracks) == 1 + assert isinstance(tracks[0], pylast.TopItem) + assert isinstance(tracks[0].item, pylast.Track) def test_network_get_top_artists_with_limit(self): # Arrange @@ -186,12 +187,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm): url = thing.get_url() # Assert - self.assertEqual(stringed, "Test Artist - Test Album") - self.assertIn("pylast.Album('Test Artist', 'Test Album',", rep) - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual("https://www.last.fm/music/test%2bartist/test%2balbum", url) + assert stringed == "Test Artist - Test Album" + assert "pylast.Album('Test Artist', 'Test Album'," in rep + assert title == name + assert isinstance(playcount, int) + assert playcount > 1 + assert "https://www.last.fm/music/test%2bartist/test%2balbum" == url def test_track_data(self): # Arrange @@ -206,15 +207,13 @@ class TestPyLastNetwork(TestPyLastWithLastFm): url = thing.get_url(pylast.DOMAIN_FRENCH) # Assert - self.assertEqual(stringed, "Test Artist - test title") - self.assertIn("pylast.Track('Test Artist', 'test title',", rep) - self.assertEqual(title, "test title") - self.assertEqual(title, name) - self.assertIsInstance(playcount, int) - self.assertGreater(playcount, 1) - self.assertEqual( - "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle", url - ) + assert stringed == "Test Artist - test title" + assert "pylast.Track('Test Artist', 'test title'," in rep + assert title == "test title" + assert title == name + assert isinstance(playcount, int) + assert playcount > 1 + assert "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle" == url def test_country_top_artists(self): # Arrange @@ -236,10 +235,10 @@ class TestPyLastNetwork(TestPyLastWithLastFm): tags2 = user.get_top_tags(limit=1, cacheable=True) # Assert - self.assertTrue(self.network.is_caching_enabled()) - self.assertEqual(tags1, tags2) + assert self.network.is_caching_enabled() + assert tags1 == tags2 self.network.disable_caching() - self.assertFalse(self.network.is_caching_enabled()) + assert not self.network.is_caching_enabled() def test_album_mbid(self): # Arrange @@ -250,9 +249,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm): album_mbid = album.get_mbid() # Assert - self.assertIsInstance(album, pylast.Album) - self.assertEqual(album.title.lower(), "test") - self.assertEqual(album_mbid, mbid) + assert isinstance(album, pylast.Album) + assert album.title.lower() == "test" + assert album_mbid == mbid def test_artist_mbid(self): # Arrange @@ -262,8 +261,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): artist = self.network.get_artist_by_mbid(mbid) # Assert - self.assertIsInstance(artist, pylast.Artist) - self.assertEqual(artist.name, "MusicBrainz Test Artist") + assert isinstance(artist, pylast.Artist) + assert artist.name == "MusicBrainz Test Artist" def test_track_mbid(self): # Arrange @@ -274,9 +273,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm): track_mbid = track.get_mbid() # Assert - self.assertIsInstance(track, pylast.Track) - self.assertEqual(track.title, "first") - self.assertEqual(track_mbid, mbid) + assert isinstance(track, pylast.Track) + assert track.title == "first" + assert track_mbid == mbid def test_init_with_token(self): # Arrange/Act @@ -291,7 +290,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): msg = str(exc) # Assert - self.assertEqual(msg, "Unauthorized Token - This token has not been issued") + assert msg == "Unauthorized Token - This token has not been issued" def test_proxy(self): # Arrange @@ -300,11 +299,11 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Act / Assert self.network.enable_proxy(host, port) - self.assertTrue(self.network.is_proxy_enabled()) - self.assertEqual(self.network._get_proxy(), ["https://example.com", 1234]) + assert self.network.is_proxy_enabled() + assert self.network._get_proxy() == ["https://example.com", 1234] self.network.disable_proxy() - self.assertFalse(self.network.is_proxy_enabled()) + assert not self.network.is_proxy_enabled() def test_album_search(self): # Arrange @@ -315,8 +314,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): results = search.get_next_page() # Assert - self.assertIsInstance(results, list) - self.assertIsInstance(results[0], pylast.Album) + assert isinstance(results, list) + assert isinstance(results[0], pylast.Album) def test_album_search_images(self): # Arrange @@ -328,15 +327,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm): images = results[0].info["image"] # Assert - self.assertEqual(len(images), 4) + assert len(images) == 4 self.assert_startswith(images[pylast.SIZE_SMALL], "https://") self.assert_endswith(images[pylast.SIZE_SMALL], ".png") - self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + assert "/34s/" in images[pylast.SIZE_SMALL] self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") - self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_artist_search(self): # Arrange @@ -347,8 +346,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): results = search.get_next_page() # Assert - self.assertIsInstance(results, list) - self.assertIsInstance(results[0], pylast.Artist) + assert isinstance(results, list) + assert isinstance(results[0], pylast.Artist) def test_artist_search_images(self): # Arrange @@ -360,15 +359,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm): images = results[0].info["image"] # Assert - self.assertEqual(len(images), 5) + assert len(images) == 5 self.assert_startswith(images[pylast.SIZE_SMALL], "https://") self.assert_endswith(images[pylast.SIZE_SMALL], ".png") - self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + assert "/34s/" in images[pylast.SIZE_SMALL] self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") - self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_track_search(self): # Arrange @@ -380,8 +379,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): results = search.get_next_page() # Assert - self.assertIsInstance(results, list) - self.assertIsInstance(results[0], pylast.Track) + assert isinstance(results, list) + assert isinstance(results[0], pylast.Track) def test_track_search_images(self): # Arrange @@ -394,15 +393,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm): images = results[0].info["image"] # Assert - self.assertEqual(len(images), 4) + assert len(images) == 4 self.assert_startswith(images[pylast.SIZE_SMALL], "https://") self.assert_endswith(images[pylast.SIZE_SMALL], ".png") - self.assertIn("/34s/", images[pylast.SIZE_SMALL]) + assert "/34s/" in images[pylast.SIZE_SMALL] self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") - self.assertIn("/300x300/", images[pylast.SIZE_EXTRA_LARGE]) + assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_search_get_total_result_count(self): # Arrange @@ -414,8 +413,4 @@ class TestPyLastNetwork(TestPyLastWithLastFm): total = search.get_total_result_count() # Assert - self.assertGreater(int(total), 10000) - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert int(total) > 10000 diff --git a/tests/test_pylast.py b/tests/test_pylast.py index c5a10b4..6b86ce9 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -5,7 +5,6 @@ Integration (not unit) tests for pylast.py import os import sys import time -import unittest import pylast import pytest @@ -33,12 +32,12 @@ def load_secrets(): return doc -class PyLastTestCase(unittest.TestCase): +class PyLastTestCase: def assert_startswith(self, str, prefix, start=None, end=None): - self.assertTrue(str.startswith(prefix, start, end)) + assert str.startswith(prefix, start, end) def assert_endswith(self, str, suffix, start=None, end=None): - self.assertTrue(str.endswith(suffix, start, end)) + assert str.endswith(suffix, start, end) @flaky(max_runs=3, min_passes=1) @@ -49,20 +48,21 @@ class TestPyLastWithLastFm(PyLastTestCase): def unix_timestamp(self): return int(time.time()) - def setUp(self): - if self.__class__.secrets is None: - self.__class__.secrets = load_secrets() + @classmethod + def setup_class(cls): + if cls.secrets is None: + cls.secrets = load_secrets() - self.username = self.__class__.secrets["username"] - password_hash = self.__class__.secrets["password_hash"] + cls.username = cls.secrets["username"] + password_hash = cls.secrets["password_hash"] - api_key = self.__class__.secrets["api_key"] - api_secret = self.__class__.secrets["api_secret"] + api_key = cls.secrets["api_key"] + api_secret = cls.secrets["api_secret"] - self.network = pylast.LastFMNetwork( + cls.network = pylast.LastFMNetwork( api_key=api_key, api_secret=api_secret, - username=self.username, + username=cls.username, password_hash=password_hash, ) @@ -74,19 +74,19 @@ class TestPyLastWithLastFm(PyLastTestCase): things.add(thing) # Assert - self.assertIsNotNone(thing) - self.assertEqual(len(things), 1) + assert thing is not None + assert len(things) == 1 def helper_validate_results(self, a, b, c): # Assert - self.assertIsNotNone(a) - self.assertIsNotNone(b) - self.assertIsNotNone(c) - self.assertGreaterEqual(len(a), 0) - self.assertGreaterEqual(len(b), 0) - self.assertGreaterEqual(len(c), 0) - self.assertEqual(a, b) - self.assertEqual(b, c) + assert a is not None + assert b is not None + assert c is not None + assert len(a) >= 0 + assert len(b) >= 0 + assert len(c) >= 0 + assert a == b + assert b == c def helper_validate_cacheable(self, thing, function_name): # Arrange @@ -103,35 +103,31 @@ class TestPyLastWithLastFm(PyLastTestCase): def helper_at_least_one_thing_in_top_list(self, things, expected_type): # Assert - self.assertGreater(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], pylast.TopItem) - self.assertIsInstance(things[0].item, expected_type) + assert len(things) > 1 + assert isinstance(things, list) + assert isinstance(things[0], pylast.TopItem) + assert isinstance(things[0].item, expected_type) def helper_only_one_thing_in_top_list(self, things, expected_type): # Assert - self.assertEqual(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], pylast.TopItem) - self.assertIsInstance(things[0].item, expected_type) + assert len(things) == 1 + assert isinstance(things, list) + assert isinstance(things[0], pylast.TopItem) + assert isinstance(things[0].item, expected_type) def helper_only_one_thing_in_list(self, things, expected_type): # Assert - self.assertEqual(len(things), 1) - self.assertIsInstance(things, list) - self.assertIsInstance(things[0], expected_type) + assert len(things) == 1 + assert isinstance(things, list) + assert isinstance(things[0], expected_type) def helper_two_different_things_in_top_list(self, things, expected_type): # Assert - self.assertEqual(len(things), 2) + assert len(things) == 2 thing1 = things[0] thing2 = things[1] - self.assertIsInstance(thing1, pylast.TopItem) - self.assertIsInstance(thing2, pylast.TopItem) - self.assertIsInstance(thing1.item, expected_type) - self.assertIsInstance(thing2.item, expected_type) - self.assertNotEqual(thing1, thing2) - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert isinstance(thing1, pylast.TopItem) + assert isinstance(thing2, pylast.TopItem) + assert isinstance(thing1.item, expected_type) + assert isinstance(thing2.item, expected_type) + assert thing1 != thing2 diff --git a/tests/test_tag.py b/tests/test_tag.py index f589b94..65544e0 100755 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -2,8 +2,6 @@ """ Integration (not unit) tests for pylast.py """ -import unittest - import pylast from .test_pylast import TestPyLastWithLastFm @@ -49,14 +47,10 @@ class TestPyLastTag(TestPyLastWithLastFm): url = tag1.get_url() # Assert - self.assertEqual("blues", tag_str) - self.assertIn("pylast.Tag", tag_repr) - self.assertIn("blues", tag_repr) - self.assertEqual("blues", name) - self.assertEqual(tag1, tag1) - self.assertNotEqual(tag1, tag2) - self.assertEqual(url, "https://www.last.fm/tag/blues") - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert "blues" == tag_str + assert "pylast.Tag" in tag_repr + assert "blues" in tag_repr + assert "blues" == name + assert tag1 == tag1 + assert tag1 != tag2 + assert url == "https://www.last.fm/tag/blues" diff --git a/tests/test_track.py b/tests/test_track.py index 9c1b898..d3f5e6a 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -3,9 +3,9 @@ Integration (not unit) tests for pylast.py """ import time -import unittest import pylast +import pytest from .test_pylast import PY37, TestPyLastWithLastFm @@ -23,10 +23,10 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert loved = lastfm_user.get_loved_tracks(limit=1) - self.assertEqual(str(loved[0].track.artist).lower(), "test artist") - self.assertEqual(str(loved[0].track.title).lower(), "test title") + assert str(loved[0].track.artist).lower() == "test artist" + assert str(loved[0].track.title).lower() == "test title" - @unittest.skipUnless(PY37, "Only run on Python 3.7 to avoid collisions") + @pytest.mark.skipif(not PY37, reason="Only run on Python 3.7 to avoid collisions") def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -42,8 +42,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert loved = lastfm_user.get_loved_tracks(limit=1) if len(loved): # OK to be empty but if not: - self.assertNotEqual(str(loved[0].track.artist), "Test Artist") - self.assertNotEqual(str(loved[0].track.title), "test title") + assert str(loved[0].track.artist) != "Test Artist" + assert str(loved[0].track.title) != "test title" def test_user_play_count_in_track_info(self): # Arrange @@ -57,7 +57,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): count = track.get_userplaycount() # Assert - self.assertGreaterEqual(count, 0) + assert count >= 0 def test_user_loved_in_track_info(self): # Arrange @@ -71,15 +71,15 @@ class TestPyLastTrack(TestPyLastWithLastFm): loved = track.get_userloved() # Assert - self.assertIsNotNone(loved) - self.assertIsInstance(loved, bool) - self.assertNotIsInstance(loved, str) + assert loved is not None + assert isinstance(loved, bool) + assert not isinstance(loved, str) def test_track_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") track = artist.get_top_tracks()[0].item - self.assertIsInstance(track, pylast.Track) + assert isinstance(track, pylast.Track) # Act/Assert self.helper_is_thing_hashable(track) @@ -92,8 +92,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): wiki = track.get_wiki_content() # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) + assert wiki is not None + assert len(wiki) >= 1 def test_track_wiki_summary(self): # Arrange @@ -103,8 +103,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): wiki = track.get_wiki_summary() # Assert - self.assertIsNotNone(wiki) - self.assertGreaterEqual(len(wiki), 1) + assert wiki is not None + assert len(wiki) >= 1 def test_track_get_duration(self): # Arrange @@ -114,7 +114,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): duration = track.get_duration() # Assert - self.assertGreaterEqual(duration, 200000) + assert duration >= 200000 def test_track_is_streamable(self): # Arrange @@ -124,7 +124,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): streamable = track.is_streamable() # Assert - self.assertFalse(streamable) + assert not streamable def test_track_is_fulltrack_available(self): # Arrange @@ -134,7 +134,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): fulltrack_available = track.is_fulltrack_available() # Assert - self.assertFalse(fulltrack_available) + assert not fulltrack_available def test_track_get_album(self): # Arrange @@ -144,7 +144,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): album = track.get_album() # Assert - self.assertEqual(str(album), "Nirvana - Nevermind") + assert str(album) == "Nirvana - Nevermind" def test_track_get_similar(self): # Arrange @@ -159,17 +159,17 @@ class TestPyLastTrack(TestPyLastWithLastFm): if str(track.item) == "Madonna - Vogue": found = True break - self.assertTrue(found) + assert found def test_track_get_similar_limits(self): # Arrange track = pylast.Track("Cher", "Believe", self.network) # Act/Assert - self.assertEqual(len(track.get_similar(limit=20)), 20) - self.assertLessEqual(len(track.get_similar(limit=10)), 10) - self.assertGreaterEqual(len(track.get_similar(limit=None)), 23) - self.assertGreaterEqual(len(track.get_similar(limit=0)), 23) + assert len(track.get_similar(limit=20)) == 20 + assert len(track.get_similar(limit=10)) <= 10 + assert len(track.get_similar(limit=None)) >= 23 + assert len(track.get_similar(limit=0)) >= 23 def test_tracks_notequal(self): # Arrange @@ -178,7 +178,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act # Assert - self.assertNotEqual(track1, track2) + assert track1 != track2 def test_track_title_prop_caps(self): # Arrange @@ -188,7 +188,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): title = track.get_title(properly_capitalized=True) # Assert - self.assertEqual(title, "Test Title") + assert title == "Test Title" def test_track_listener_count(self): # Arrange @@ -198,7 +198,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): count = track.get_listener_count() # Assert - self.assertGreater(count, 21) + assert count > 21 def test_album_tracks(self): # Arrange @@ -209,10 +209,10 @@ class TestPyLastTrack(TestPyLastWithLastFm): url = tracks[0].get_url() # Assert - self.assertIsInstance(tracks, list) - self.assertIsInstance(tracks[0], pylast.Track) - self.assertEqual(len(tracks), 1) - self.assertTrue(url.startswith("https://www.last.fm/music/test")) + assert isinstance(tracks, list) + assert isinstance(tracks[0], pylast.Track) + assert len(tracks) == 1 + assert url.startswith("https://www.last.fm/music/test") def test_track_eq_none_is_false(self): # Arrange @@ -220,7 +220,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert - self.assertNotEqual(track1, track2) + assert track1 != track2 def test_track_ne_none_is_true(self): # Arrange @@ -228,7 +228,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): track2 = pylast.Track("Test Artist", "test title", self.network) # Act / Assert - self.assertNotEqual(track1, track2) + assert track1 != track2 def test_track_get_correction(self): # Arrange @@ -238,7 +238,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): corrected_track_name = track.get_correction() # Assert - self.assertEqual(corrected_track_name, "Mr. Brownstone") + assert corrected_track_name == "Mr. Brownstone" def test_track_with_no_mbid(self): # Arrange @@ -248,8 +248,4 @@ class TestPyLastTrack(TestPyLastWithLastFm): mbid = track.get_mbid() # Assert - self.assertIsNone(mbid) - - -if __name__ == "__main__": - unittest.main(failfast=True) + assert mbid is None diff --git a/tests/test_user.py b/tests/test_user.py index e371529..b0ae898 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -5,7 +5,7 @@ Integration (not unit) tests for pylast.py import calendar import datetime as dt import os -import unittest +import re import warnings import pylast @@ -33,7 +33,7 @@ class TestPyLastUser(TestPyLastWithLastFm): string = str(user) # Assert - self.assertEqual(string, "RJ") + assert string == "RJ" def test_equality(self): # Arrange @@ -43,9 +43,9 @@ class TestPyLastUser(TestPyLastWithLastFm): not_a_user = self.network # Act / Assert - self.assertEqual(user_1a, user_1b) - self.assertNotEqual(user_1a, user_2) - self.assertNotEqual(user_1a, not_a_user) + assert user_1a == user_1b + assert user_1a != user_2 + assert user_1a != not_a_user def test_get_name(self): # Arrange @@ -55,7 +55,7 @@ class TestPyLastUser(TestPyLastWithLastFm): name = user.get_name(properly_capitalized=True) # Assert - self.assertEqual(name, "RJ") + assert name == "RJ" def test_get_user_registration(self): # Arrange @@ -67,11 +67,11 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert if int(registered): # Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp - self.assertEqual(registered, "1037793040") + assert registered == "1037793040" else: # Old way # Just check date because of timezones - self.assertIn("2002-11-20 ", registered) + assert "2002-11-20 " in registered def test_get_user_unixtime_registration(self): # Arrange @@ -82,7 +82,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert # Just check date because of timezones - self.assertEqual(unixtime_registered, 1037793040) + assert unixtime_registered == 1037793040 def test_get_countryless_user(self): # Arrange @@ -93,7 +93,7 @@ class TestPyLastUser(TestPyLastWithLastFm): country = lastfm_user.get_country() # Assert - self.assertIsNone(country) + assert country is None def test_user_get_country(self): # Arrange @@ -103,7 +103,7 @@ class TestPyLastUser(TestPyLastWithLastFm): country = lastfm_user.get_country() # Assert - self.assertEqual(str(country), "United Kingdom") + assert str(country) == "United Kingdom" def test_user_equals_none(self): # Arrange @@ -113,7 +113,7 @@ class TestPyLastUser(TestPyLastWithLastFm): value = lastfm_user is None # Assert - self.assertFalse(value) + assert not value def test_user_not_equal_to_none(self): # Arrange @@ -123,7 +123,7 @@ class TestPyLastUser(TestPyLastWithLastFm): value = lastfm_user is not None # Assert - self.assertTrue(value) + assert value def test_now_playing_user_with_no_scrobbles(self): # Arrange @@ -134,7 +134,7 @@ class TestPyLastUser(TestPyLastWithLastFm): current_track = user.get_now_playing() # Assert - self.assertIsNone(current_track) + assert current_track is None def test_love_limits(self): # Arrange @@ -142,10 +142,10 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("test-user") # Act/Assert - self.assertEqual(len(user.get_loved_tracks(limit=20)), 20) - self.assertLessEqual(len(user.get_loved_tracks(limit=100)), 100) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=None)), 23) - self.assertGreaterEqual(len(user.get_loved_tracks(limit=0)), 23) + assert len(user.get_loved_tracks(limit=20)) == 20 + assert len(user.get_loved_tracks(limit=100)) <= 100 + assert len(user.get_loved_tracks(limit=None)) >= 23 + assert len(user.get_loved_tracks(limit=0)) >= 23 def test_user_is_hashable(self): # Arrange @@ -183,7 +183,7 @@ class TestPyLastUser(TestPyLastWithLastFm): os.remove(filename) # Assert - self.assertEqual(lastfm_user, loaded_user) + assert lastfm_user == loaded_user @pytest.mark.xfail def test_cacheable_user(self): @@ -217,10 +217,10 @@ class TestPyLastUser(TestPyLastWithLastFm): def helper_assert_chart(self, chart, expected_type): # Assert - self.assertIsNotNone(chart) - self.assertGreater(len(chart), 0) - self.assertIsInstance(chart[0], pylast.TopItem) - self.assertIsInstance(chart[0].item, expected_type) + assert chart is not None + assert len(chart) > 0 + assert isinstance(chart[0], pylast.TopItem) + assert isinstance(chart[0].item, expected_type) def helper_get_assert_charts(self, thing, date): # Arrange @@ -241,10 +241,10 @@ class TestPyLastUser(TestPyLastWithLastFm): def helper_dates_valid(self, dates): # Assert - self.assertGreaterEqual(len(dates), 1) - self.assertIsInstance(dates[0], tuple) + assert len(dates) >= 1 + assert isinstance(dates[0], tuple) (start, end) = dates[0] - self.assertLess(start, end) + assert start < end def test_user_charts(self): # Arrange @@ -276,8 +276,8 @@ class TestPyLastUser(TestPyLastWithLastFm): self.helper_only_one_thing_in_top_list(albums, pylast.Album) top_album = albums[0].item - self.assertTrue(len(top_album.info["image"])) - self.assertRegex(top_album.info["image"][pylast.SIZE_LARGE], r"^http.+$") + assert len(top_album.info["image"]) + assert re.search(r"^http.+$", top_album.info["image"][pylast.SIZE_LARGE]) def test_user_tagged_artists(self): # Arrange @@ -328,8 +328,8 @@ class TestPyLastUser(TestPyLastWithLastFm): non_subscriber_is_subscriber = non_subscriber.is_subscriber() # Assert - self.assertTrue(subscriber_is_subscriber) - self.assertFalse(non_subscriber_is_subscriber) + assert subscriber_is_subscriber + assert not non_subscriber_is_subscriber def test_user_get_image(self): # Arrange @@ -349,7 +349,7 @@ class TestPyLastUser(TestPyLastWithLastFm): library = user.get_library() # Assert - self.assertIsInstance(library, pylast.Library) + assert isinstance(library, pylast.Library) def test_get_recent_tracks_from_to(self): # Arrange @@ -364,9 +364,9 @@ class TestPyLastUser(TestPyLastWithLastFm): tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end) # Assert - self.assertEqual(len(tracks), 1) - self.assertEqual(str(tracks[0].track.artist), "Johnny Cash") - self.assertEqual(str(tracks[0].track.title), "Ring of Fire") + assert len(tracks) == 1 + assert str(tracks[0].track.artist) == "Johnny Cash" + assert str(tracks[0].track.title) == "Ring of Fire" def test_get_recent_tracks_limit_none(self): # Arrange @@ -383,9 +383,9 @@ class TestPyLastUser(TestPyLastWithLastFm): ) # Assert - self.assertEqual(len(tracks), 11) - self.assertEqual(str(tracks[0].track.artist), "Seun Kuti & Egypt 80") - self.assertEqual(str(tracks[0].track.title), "Struggles Sounds") + assert len(tracks) == 11 + assert str(tracks[0].track.artist) == "Seun Kuti & Egypt 80" + assert str(tracks[0].track.title) == "Struggles Sounds" def test_get_playcount(self): # Arrange @@ -395,7 +395,7 @@ class TestPyLastUser(TestPyLastWithLastFm): playcount = user.get_playcount() # Assert - self.assertGreaterEqual(playcount, 128387) + assert playcount >= 128387 def test_get_image(self): # Arrange @@ -416,7 +416,7 @@ class TestPyLastUser(TestPyLastWithLastFm): url = user.get_url() # Assert - self.assertEqual(url, "https://www.last.fm/user/rj") + assert url == "https://www.last.fm/user/rj" def test_get_weekly_artist_charts(self): # Arrange @@ -427,8 +427,8 @@ class TestPyLastUser(TestPyLastWithLastFm): artist, weight = charts[0] # Assert - self.assertIsNotNone(artist) - self.assertIsInstance(artist.network, pylast.LastFMNetwork) + assert artist is not None + assert isinstance(artist.network, pylast.LastFMNetwork) def test_get_weekly_track_charts(self): # Arrange @@ -439,8 +439,8 @@ class TestPyLastUser(TestPyLastWithLastFm): track, weight = charts[0] # Assert - self.assertIsNotNone(track) - self.assertIsInstance(track.network, pylast.LastFMNetwork) + assert track is not None + assert isinstance(track.network, pylast.LastFMNetwork) def test_user_get_track_scrobbles(self): # Arrange @@ -452,9 +452,9 @@ class TestPyLastUser(TestPyLastWithLastFm): scrobbles = user.get_track_scrobbles(artist, title) # Assert - self.assertGreater(len(scrobbles), 0) - self.assertEqual(str(scrobbles[0].track.artist), "France Gall") - self.assertEqual(scrobbles[0].track.title, "Laisse Tomber Les Filles") + assert len(scrobbles) > 0 + assert str(scrobbles[0].track.artist) == "France Gall" + assert scrobbles[0].track.title == "Laisse Tomber Les Filles" def test_cacheable_user_get_track_scrobbles(self): # Arrange @@ -475,12 +475,9 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user(self.username) # Act / Assert - with warnings.catch_warnings(), self.assertRaisesRegex( - pylast.WSError, "Deprecated - This type of request is no longer supported" + with warnings.catch_warnings(), pytest.raises( + pylast.WSError, + match="Deprecated - This type of request is no longer supported", ): warnings.filterwarnings("ignore", category=DeprecationWarning) lastfm_user.get_artist_tracks(artist="Test Artist") - - -if __name__ == "__main__": - unittest.main(failfast=True) From d467e2ceb7eed1e1b8b52b19ffed8d59e3d60ea1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 1 Jun 2020 21:45:11 +0300 Subject: [PATCH 564/768] If we already have the album, return early --- src/pylast/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 4befdb7..21e2140 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2128,6 +2128,8 @@ class Track(_Opus): def get_album(self): """Returns the album object of this track.""" + if "album" in self.info and self.info["album"] is not None: + return Album(self.artist, self.info["album"], self.network) doc = self._request(self.ws_prefix + ".getInfo", True) From 6f6285716450dd7bb16b3bb9c378bffeb865bcab Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 2 Jun 2020 16:02:26 +0300 Subject: [PATCH 565/768] Add test for get_album() changes --- tests/test_network.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_network.py b/tests/test_network.py index 85362a3..8c84760 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -49,6 +49,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert str(current_track.title).lower() == "test title" assert str(current_track.artist).lower() == "test artist" assert current_track.info["album"] == "Test Album" + assert current_track.get_album().title == "Test Album" assert len(current_track.info["image"]) assert re.search(r"^http.+$", current_track.info["image"][pylast.SIZE_LARGE]) From 158273e0ae9bd6ab2443269feb7265dff2efc0d0 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 22 Jun 2020 22:20:29 +0300 Subject: [PATCH 566/768] Deprecate Artist.get_cover_image, they're no longer available from Last.fm --- src/pylast/__init__.py | 9 +++++++++ tests/test_artist.py | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 21e2140..fb2de22 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1726,6 +1726,15 @@ class Artist(_BaseObject, _Taggable): SIZE_SMALL """ + warnings.warn( + "Artist.get_cover_image is deprecated and will be removed in a future " + "version. In the meantime, only default star images are available. " + "See https://github.com/pylast/pylast/issues/317 and " + "https://support.last.fm/t/api-announcement/202", + DeprecationWarning, + stacklevel=2, + ) + if "image" not in self.info: self.info["image"] = _extract_all( self._request(self.ws_prefix + ".getInfo", cacheable=True), "image" diff --git a/tests/test_artist.py b/tests/test_artist.py index aa3e007..435db95 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -250,7 +250,10 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Act url = artist1.get_url() mbid = artist1.get_mbid() - image = artist1.get_cover_image() + + with pytest.warns(DeprecationWarning): + image = artist1.get_cover_image() + playcount = artist1.get_playcount() streamable = artist1.is_streamable() name = artist1.get_name(properly_capitalized=False) From e188e78bddd686dd0197e7071c9ff7da8b41c7a8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 23 Jun 2020 11:21:40 +0300 Subject: [PATCH 567/768] pre-commit autoupdate, show diff on failure, and force colour on GHA --- .pre-commit-config.yaml | 6 +++--- tox.ini | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8461ec4..da1e286 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.4.4 + rev: v2.6.1 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -15,13 +15,13 @@ repos: types: [] - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.2 + rev: 3.8.3 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/asottile/seed-isort-config - rev: v2.1.1 + rev: v2.2.0 hooks: - id: seed-isort-config diff --git a/tox.ini b/tox.ini index f6e43d7..ecb5bd7 100644 --- a/tox.ini +++ b/tox.ini @@ -16,5 +16,6 @@ commands = {posargs} [testenv:lint] deps = pre-commit -commands = pre-commit run --all-files +commands = pre-commit run --all-files --show-diff-on-failure skip_install = true +passenv = PRE_COMMIT_COLOR From 32461c089d95356894612b3bdf229f6b3f84881a Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 24 Jun 2020 22:58:25 +0300 Subject: [PATCH 568/768] Only test write operations on a single Python version to avoid collision failures --- tests/test_artist.py | 7 ++++++- tests/test_network.py | 5 +++-- tests/test_pylast.py | 2 +- tests/test_track.py | 5 +++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 435db95..8250f5b 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -5,7 +5,7 @@ Integration (not unit) tests for pylast.py import pylast import pytest -from .test_pylast import TestPyLastWithLastFm +from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastArtist(TestPyLastWithLastFm): @@ -141,6 +141,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert isinstance(count, int) assert count > 0 + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_tag_artist(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -159,6 +160,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): break assert found + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tag_of_type_text(self): # Arrange tag = "testing" # text @@ -177,6 +179,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): break assert not found + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tag_of_type_tag(self): # Arrange tag = pylast.Tag("testing", self.network) # Tag @@ -195,6 +198,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): break assert not found + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_remove_tags(self): # Arrange tags = ["removetag1", "removetag2"] @@ -218,6 +222,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert not found1 assert not found2 + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_set_tags(self): # Arrange tags = ["sometag1", "sometag2"] diff --git a/tests/test_network.py b/tests/test_network.py index 8c84760..bad8c54 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -8,11 +8,11 @@ import time import pylast import pytest -from .test_pylast import PY37, TestPyLastWithLastFm +from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): - @pytest.mark.skipif(not PY37, reason="Only run on Python 3.7 to avoid collisions") + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_scrobble(self): # Arrange artist = "test artist" @@ -30,6 +30,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert str(last_scrobble.track.artist).lower() == artist assert str(last_scrobble.track.title).lower() == title + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_update_now_playing(self): # Arrange artist = "Test Artist" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 6b86ce9..83a64ad 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -10,7 +10,7 @@ import pylast import pytest from flaky import flaky -PY37 = sys.version_info[:2] == (3, 7) +WRITE_TEST = sys.version_info[:2] == (3, 8) def load_secrets(): diff --git a/tests/test_track.py b/tests/test_track.py index d3f5e6a..3bfe995 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -7,10 +7,11 @@ import time import pylast import pytest -from .test_pylast import PY37, TestPyLastWithLastFm +from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_love(self): # Arrange artist = "Test Artist" @@ -26,7 +27,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert str(loved[0].track.artist).lower() == "test artist" assert str(loved[0].track.title).lower() == "test title" - @pytest.mark.skipif(not PY37, reason="Only run on Python 3.7 to avoid collisions") + @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") def test_unlove(self): # Arrange artist = pylast.Artist("Test Artist", self.network) From 00f7014c513e9727e8559e93631077e03fd79810 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 25 Jun 2020 19:13:46 +0300 Subject: [PATCH 569/768] Add 3.3.0 to changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a00351..f1d7042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.3.0] - 2020-06-25 +### Added + +* `User.get_now_playing`: Add album and cover image to info (#330) @hugovk + +### Changed + +* Improve handling of error responses from the API (#327) @spiritualized + +### Deprecated + +* Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) @hugovk + +### Fixed + +* Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk + + ## [3.2.1] - 2020-03-05 ### Fixed @@ -67,6 +86,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) +[3.3.0]: https://github.com/pylast/pylast/compare/v3.2.1...3.3.0 [3.2.1]: https://github.com/pylast/pylast/compare/v3.2.0...3.2.1 [3.2.0]: https://github.com/pylast/pylast/compare/v3.1.0...3.2.0 [3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 From 1844c65f81652dbac94019fb31d46519c16703fd Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 25 Jun 2020 19:24:29 +0300 Subject: [PATCH 570/768] Check importable and version is updated --- RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 6842e8f..4224482 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -19,5 +19,5 @@ * [ ] Check installation: ```bash -pip3 uninstall -y pylast && pip3 install -U pylast +pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)" ``` From 0300b52b4b711fcb31cb096ae3432fc6ecf68761 Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 25 Jun 2020 20:35:19 +0300 Subject: [PATCH 571/768] Test Python 3.9-dev and 3.10-dev on Travis CI --- .travis.yml | 2 ++ tox.ini | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 86bec65..f904565 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,6 +24,8 @@ matrix: - python: 3.7 - python: 3.6 - python: 3.5 + - python: 3.9-dev + - python: 3.10-dev - python: pypy3 install: diff --git a/tox.ini b/tox.ini index ecb5bd7..87367e0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py37, py36, py35, pypy3 +envlist = py{36, 37, 38, 39, 310, py3} [testenv] extras = tests From 7c42504d6809fae31fba039c498153138e669a64 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 25 Jun 2020 21:05:40 +0300 Subject: [PATCH 572/768] Mergify: configuration update --- .mergify.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..e7c13d0 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,9 @@ +pull_request_rules: + - name: Automatic merge on approval + conditions: + - label=automerge + - status-success=continuous-integration/travis-ci/pr + - status-success=continuous-integration/travis-ci/push + actions: + merge: + method: merge From 43971c05fdcbc3e60a3cc9f36454216db3acdc6f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 25 Jun 2020 21:09:46 +0300 Subject: [PATCH 573/768] Also require GHA lint The checks show up like this: Lint / build (pull_request) Lint / build (push) And the condition needs to match the job name, "build". https://doc.mergify.io/conditions.html#github-actions TODO: rename "build"->"lint" in lint.yml --- .mergify.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.mergify.yml b/.mergify.yml index e7c13d0..f2aad55 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -2,6 +2,7 @@ pull_request_rules: - name: Automatic merge on approval conditions: - label=automerge + - status-success=build - status-success=continuous-integration/travis-ci/pr - status-success=continuous-integration/travis-ci/push actions: From c2da9a6365c61e53e79020a8edff1aff55a776a2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 12 Jul 2020 14:19:22 +0300 Subject: [PATCH 574/768] Add automerge label for Mergify --- .github/labels.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/labels.yml b/.github/labels.yml index e133e75..6ea43df 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -91,6 +91,12 @@ - color: b60205 description: Removal of a feature, usually done in major releases name: removal +- color: 2d18b2 + description: "To automatically merge PRs that are ready" + name: automerge +- color: 0366d6 + description: "For dependencies" + name: dependencies - color: fef2c0 description: "" name: test From 92004058ba66be45301ffe4adc2033bea64c4bbf Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Sun, 12 Jul 2020 11:54:46 +0200 Subject: [PATCH 575/768] Added option to stream from resources to reduce memory usage --- README.md | 2 +- src/pylast/__init__.py | 293 +++++++++++++++++++---------------------- tests/test_album.py | 2 +- tests/test_artist.py | 12 +- tests/test_network.py | 8 +- tests/test_pylast.py | 15 ++- tests/test_track.py | 8 +- tests/test_user.py | 26 ++-- 8 files changed, 174 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 806c718..1ec2e8f 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh -pip install pytest flaky +pip install .[tests] pytest ``` diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index fb2de22..f3a61f6 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1149,21 +1149,22 @@ class _BaseObject: return first_child.wholeText.strip() - def _get_things(self, method, thing, thing_type, params=None, cacheable=True): + def _get_things(self, method, thing, thing_type, params=None, cacheable=True, stream=False): """Returns a list of the most played thing_types by this thing.""" - limit = params.get("limit", 1) - seq = [] - for node in _collect_nodes( - limit, self, self.ws_prefix + "." + method, cacheable, params - ): - title = _extract(node, "name") - artist = _extract(node, "name", 1) - playcount = _number(_extract(node, "playcount")) + def _stream_get_things(): + limit = params.get("limit", 1) + nodes = _collect_nodes( + limit, self, self.ws_prefix + "." + method, cacheable, params, stream=stream, + ) + for node in nodes: + title = _extract(node, "name") + artist = _extract(node, "name", 1) + playcount = _number(_extract(node, "playcount")) - seq.append(TopItem(thing_type(artist, title, self.network), playcount)) + yield TopItem(thing_type(artist, title, self.network), playcount) - return seq + return _stream_get_things() if stream else list(_stream_get_things()) def get_wiki_published_date(self): """ @@ -1835,21 +1836,21 @@ class Artist(_BaseObject, _Taggable): return artists - def get_top_albums(self, limit=None, cacheable=True): + def get_top_albums(self, limit=None, cacheable=True, stream=True): """Returns a list of the top albums.""" params = self._get_params() if limit: params["limit"] = limit - return self._get_things("getTopAlbums", "album", Album, params, cacheable) + return self._get_things("getTopAlbums", "album", Album, params, cacheable, stream=stream) - def get_top_tracks(self, limit=None, cacheable=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=True): """Returns a list of the most played Tracks by this artist.""" params = self._get_params() if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. @@ -1917,13 +1918,13 @@ class Country(_BaseObject): return _extract_top_artists(doc, self) - def get_top_tracks(self, limit=None, cacheable=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=True): """Returns a sequence of the most played tracks""" params = self._get_params() if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. @@ -1978,24 +1979,24 @@ class Library(_BaseObject): """Returns the user who owns this library.""" return self.user - def get_artists(self, limit=50, cacheable=True): + def get_artists(self, limit=50, cacheable=True, stream=True): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) """ - seq = [] - for node in _collect_nodes( - limit, self, self.ws_prefix + ".getArtists", cacheable - ): - name = _extract(node, "name") + def _get_artists(): + for node in _collect_nodes( + limit, self, self.ws_prefix + ".getArtists", cacheable, stream=stream + ): + name = _extract(node, "name") - playcount = _number(_extract(node, "playcount")) - tagcount = _number(_extract(node, "tagcount")) + playcount = _number(_extract(node, "playcount")) + tagcount = _number(_extract(node, "tagcount")) - seq.append(LibraryItem(Artist(name, self.network), playcount, tagcount)) + yield LibraryItem(Artist(name, self.network), playcount, tagcount) - return seq + return _get_artists() if stream else list(_get_artists()) class Tag(_BaseObject, _Chartable): @@ -2047,13 +2048,13 @@ class Tag(_BaseObject, _Chartable): return _extract_top_albums(doc, self.network) - def get_top_tracks(self, limit=None, cacheable=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=True): """Returns a list of the most played Tracks for this tag.""" params = self._get_params() if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" @@ -2241,6 +2242,14 @@ class User(_BaseObject, _Chartable): def _get_params(self): return {self.ws_prefix: self.get_name()} + def _extract_played_track(self, track_node): + title = _extract(track_node, "name") + track_artist = _extract(track_node, "artist") + date = _extract(track_node, "date") + album = _extract(track_node, "album") + timestamp = track_node.getElementsByTagName("date")[0].getAttribute("uts") + return PlayedTrack(Track(track_artist, title, self.network), album, date, timestamp) + def get_name(self, properly_capitalized=False): """Returns the user name.""" @@ -2251,7 +2260,7 @@ class User(_BaseObject, _Chartable): return self.name - def get_artist_tracks(self, artist, cacheable=False): + def get_artist_tracks(self, artist, cacheable=False, stream=True): """ Deprecated by Last.fm. Get a list of tracks by a given artist scrobbled by this user, @@ -2269,63 +2278,56 @@ class User(_BaseObject, _Chartable): params = self._get_params() params["artist"] = artist - seq = [] - for track in _collect_nodes( - None, self, self.ws_prefix + ".getArtistTracks", cacheable, params - ): - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + def _get_artist_tracks(): + for track_node in _collect_nodes( + None, self, self.ws_prefix + ".getArtistTracks", cacheable, params, stream=stream, + ): + yield self._extract_played_track(track_node=track_node) - seq.append( - PlayedTrack(Track(artist, title, self.network), album, date, timestamp) - ) + return _get_artist_tracks() if stream else list(_get_artist_tracks()) - return seq - - def get_friends(self, limit=50, cacheable=False): + def get_friends(self, limit=50, cacheable=False, stream=True): """Returns a list of the user's friends. """ - seq = [] - for node in _collect_nodes( - limit, self, self.ws_prefix + ".getFriends", cacheable - ): - seq.append(User(_extract(node, "name"), self.network)) + def _get_friends(): + for node in _collect_nodes( + limit, self, self.ws_prefix + ".getFriends", cacheable, stream=stream + ): + yield User(_extract(node, "name"), self.network) - return seq + return _get_friends() if stream else list(_get_friends()) - def get_loved_tracks(self, limit=50, cacheable=True): + def get_loved_tracks(self, limit=50, cacheable=True, stream=True): """ Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. + If stream=True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. """ - params = self._get_params() - if limit: - params["limit"] = limit + def _get_loved_tracks(): + params = self._get_params() + if limit: + params["limit"] = limit - seq = [] - for track in _collect_nodes( - limit, self, self.ws_prefix + ".getLovedTracks", cacheable, params - ): - try: - artist = _extract(track, "name", 1) - except IndexError: # pragma: no cover - continue - title = _extract(track, "name") - date = _extract(track, "date") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + for track in _collect_nodes( + limit, self, self.ws_prefix + ".getLovedTracks", cacheable, params, stream=stream + ): + try: + artist = _extract(track, "name", 1) + except IndexError: # pragma: no cover + continue + title = _extract(track, "name") + date = _extract(track, "date") + timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - seq.append(LovedTrack(Track(artist, title, self.network), date, timestamp)) + yield LovedTrack(Track(artist, title, self.network), date, timestamp) - return seq + return _get_loved_tracks() if stream else list(_get_loved_tracks()) def get_now_playing(self): """ @@ -2353,7 +2355,7 @@ class User(_BaseObject, _Chartable): return Track(artist, title, self.network, self.name, info=info) - def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None): + def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None, stream=True): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. @@ -2368,45 +2370,35 @@ class User(_BaseObject, _Chartable): before this time, in UNIX timestamp format (integer number of seconds since 00:00:00, January 1st 1970 UTC). This must be in the UTC time zone. + stream: If True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. """ - params = self._get_params() - if limit: - params["limit"] = limit + 1 # in case we remove the now playing track - if time_from: - params["from"] = time_from - if time_to: - params["to"] = time_to + def _get_recent_tracks(): + params = self._get_params() + if limit: + params["limit"] = limit + 1 # in case we remove the now playing track + if time_from: + params["from"] = time_from + if time_to: + params["to"] = time_to - seq = [] - for track in _collect_nodes( - limit + 1 if limit else None, - self, - self.ws_prefix + ".getRecentTracks", - cacheable, - params, - ): + for track_node in _collect_nodes( + limit + 1 if limit else None, + self, + self.ws_prefix + ".getRecentTracks", + cacheable, + params, + stream=stream + ): + if track_node.hasAttribute("nowplaying"): + continue # to prevent the now playing track from sneaking in - if track.hasAttribute("nowplaying"): - continue # to prevent the now playing track from sneaking in + yield self._extract_played_track(track_node=track_node) - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - - seq.append( - PlayedTrack(Track(artist, title, self.network), album, date, timestamp) - ) - - if limit: - # Slice, in case we didn't remove a now playing track - seq = seq[:limit] - return seq + return _get_recent_tracks() if stream else list(_get_recent_tracks()) def get_country(self): """Returns the name of the country of the user.""" @@ -2545,7 +2537,7 @@ class User(_BaseObject, _Chartable): return seq - def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True): + def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=True): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -2561,33 +2553,24 @@ class User(_BaseObject, _Chartable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) - def get_track_scrobbles(self, artist, track, cacheable=False): + def get_track_scrobbles(self, artist, track, cacheable=False, stream=True): """ Get a list of this user's scrobbles of this artist's track, including scrobble time. """ - params = self._get_params() params["artist"] = artist params["track"] = track - seq = [] - for track in _collect_nodes( - None, self, self.ws_prefix + ".getTrackScrobbles", cacheable, params - ): - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") + def _get_track_scrobbles(): + for track_node in _collect_nodes( + None, self, self.ws_prefix + ".getTrackScrobbles", cacheable, params, stream=stream + ): + yield self._extract_played_track(track_node) - seq.append( - PlayedTrack(Track(artist, title, self.network), album, date, timestamp) - ) - - return seq + return _get_track_scrobbles() if stream else list(_get_track_scrobbles()) def get_image(self, size=SIZE_EXTRA_LARGE): """ @@ -2797,59 +2780,57 @@ def cleanup_nodes(doc): return doc -def _collect_nodes(limit, sender, method_name, cacheable, params=None): +def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=False): """ Returns a sequence of dom.Node objects about as close to limit as possible """ - if not params: params = sender._get_params() - nodes = [] - page = 1 - end_of_pages = False + def _stream_collect_nodes(): + node_count = 0 + page = 1 + end_of_pages = False - while not end_of_pages and (not limit or (limit and len(nodes) < limit)): - params["page"] = str(page) + while not end_of_pages and (not limit or (limit and node_count < limit)): + params["page"] = str(page) - tries = 1 - while True: - try: - doc = sender._request(method_name, cacheable, params) - break # success - except Exception as e: - if tries >= 3: - raise e - # Wait and try again - time.sleep(1) - tries += 1 + tries = 1 + while True: + try: + doc = sender._request(method_name, cacheable, params) + break # success + except Exception as e: + if tries >= 3: + raise e + # Wait and try again + time.sleep(1) + tries += 1 - doc = cleanup_nodes(doc) + doc = cleanup_nodes(doc) - # break if there are no child nodes - if not doc.documentElement.childNodes: - break - main = doc.documentElement.childNodes[0] + # break if there are no child nodes + if not doc.documentElement.childNodes: + break + main = doc.documentElement.childNodes[0] - if main.hasAttribute("totalPages"): - total_pages = _number(main.getAttribute("totalPages")) - elif main.hasAttribute("totalpages"): - total_pages = _number(main.getAttribute("totalpages")) - else: - raise Exception("No total pages attribute") + if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"): + total_pages = _number(main.getAttribute("totalPages") or main.getAttribute("totalpages")) + else: + raise Exception("No total pages attribute") - for node in main.childNodes: - if not node.nodeType == xml.dom.Node.TEXT_NODE and ( - not limit or (len(nodes) < limit) - ): - nodes.append(node) + for node in main.childNodes: + if not node.nodeType == xml.dom.Node.TEXT_NODE and ( + not limit or (node_count < limit) + ): + node_count += 1 + yield node - if page >= total_pages: - end_of_pages = True + end_of_pages = page >= total_pages - page += 1 + page += 1 - return nodes + return _stream_collect_nodes() if stream else list(_stream_collect_nodes()) def _extract(node, name, index=0): diff --git a/tests/test_album.py b/tests/test_album.py index 8a213b5..d6bf3e1 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -32,7 +32,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Act # limit=2 to ignore now-playing: - track = lastfm_user.get_recent_tracks(limit=2)[0] + track = list(lastfm_user.get_recent_tracks(limit=2))[0] # Assert assert hasattr(track, "album") diff --git a/tests/test_artist.py b/tests/test_artist.py index 8250f5b..8f9a97e 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,9 +2,9 @@ """ Integration (not unit) tests for pylast.py """ -import pylast import pytest +import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm @@ -78,7 +78,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_tracks(limit=2) + things = artist.get_top_tracks(limit=2, stream=False) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -89,7 +89,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=2) + things = list(artist.get_top_albums(limit=2)) # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) @@ -101,7 +101,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit) + things = artist.get_top_albums(limit=limit, stream=False) # Assert assert len(things) == 1 @@ -113,7 +113,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit) + things = artist.get_top_albums(limit=limit, stream=False) # Assert assert len(things) == 50 @@ -125,7 +125,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit) + things = list(artist.get_top_albums(limit=limit)) # Assert assert len(things) == 100 diff --git a/tests/test_network.py b/tests/test_network.py index bad8c54..7c2e68d 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,9 +5,9 @@ Integration (not unit) tests for pylast.py import re import time -import pylast import pytest +import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm @@ -26,7 +26,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert # limit=2 to ignore now-playing: - last_scrobble = lastfm_user.get_recent_tracks(limit=2)[0] + last_scrobble = list(lastfm_user.get_recent_tracks(limit=2))[0] assert str(last_scrobble.track.artist).lower() == artist assert str(last_scrobble.track.title).lower() == title @@ -153,7 +153,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): country = self.network.get_country("Croatia") # Act - things = country.get_top_tracks(limit=2) + things = country.get_top_tracks(limit=2, stream=False) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -171,7 +171,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): tag = self.network.get_tag("blues") # Act - things = tag.get_top_tracks(limit=2) + things = tag.get_top_tracks(limit=2, stream=False) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 83a64ad..20d1604 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -6,10 +6,11 @@ import os import sys import time -import pylast import pytest from flaky import flaky +import pylast + WRITE_TEST = sys.version_info[:2] == (3, 8) @@ -82,9 +83,9 @@ class TestPyLastWithLastFm(PyLastTestCase): assert a is not None assert b is not None assert c is not None - assert len(a) >= 0 - assert len(b) >= 0 - assert len(c) >= 0 + assert isinstance(len(a), int) + assert isinstance(len(b), int) + assert isinstance(len(c), int) assert a == b assert b == c @@ -94,9 +95,9 @@ class TestPyLastWithLastFm(PyLastTestCase): func = getattr(thing, function_name, None) # Act - result1 = func(limit=1, cacheable=False) - result2 = func(limit=1, cacheable=True) - result3 = func(limit=1) + result1 = func(limit=1, cacheable=False, stream=False) + result2 = func(limit=1, cacheable=True, stream=False) + result3 = list(func(limit=1)) # Assert self.helper_validate_results(result1, result2, result3) diff --git a/tests/test_track.py b/tests/test_track.py index 3bfe995..8ab6faa 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -4,9 +4,9 @@ Integration (not unit) tests for pylast.py """ import time -import pylast import pytest +import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm @@ -23,7 +23,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): track.love() # Assert - loved = lastfm_user.get_loved_tracks(limit=1) + loved = list(lastfm_user.get_loved_tracks(limit=1)) assert str(loved[0].track.artist).lower() == "test artist" assert str(loved[0].track.title).lower() == "test title" @@ -41,7 +41,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later? # Assert - loved = lastfm_user.get_loved_tracks(limit=1) + loved = list(lastfm_user.get_loved_tracks(limit=1)) if len(loved): # OK to be empty but if not: assert str(loved[0].track.artist) != "Test Artist" assert str(loved[0].track.title) != "test title" @@ -79,7 +79,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): def test_track_is_hashable(self): # Arrange artist = self.network.get_artist("Test Artist") - track = artist.get_top_tracks()[0].item + track = artist.get_top_tracks(stream=False)[0].item assert isinstance(track, pylast.Track) # Act/Assert diff --git a/tests/test_user.py b/tests/test_user.py index b0ae898..5415cc8 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -8,9 +8,9 @@ import os import re import warnings -import pylast import pytest +import pylast from .test_pylast import TestPyLastWithLastFm @@ -142,10 +142,10 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("test-user") # Act/Assert - assert len(user.get_loved_tracks(limit=20)) == 20 - assert len(user.get_loved_tracks(limit=100)) <= 100 - assert len(user.get_loved_tracks(limit=None)) >= 23 - assert len(user.get_loved_tracks(limit=0)) >= 23 + assert len(user.get_loved_tracks(limit=20, stream=False)) == 20 + assert len(user.get_loved_tracks(limit=100, stream=False)) <= 100 + assert len(user.get_loved_tracks(limit=None, stream=False)) >= 23 + assert len(user.get_loved_tracks(limit=0, stream=False)) >= 23 def test_user_is_hashable(self): # Arrange @@ -210,7 +210,7 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user("RJ") # Act - things = lastfm_user.get_top_tracks(limit=2) + things = lastfm_user.get_top_tracks(limit=2, stream=False) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -361,7 +361,7 @@ class TestPyLastUser(TestPyLastWithLastFm): utc_end = calendar.timegm(end.utctimetuple()) # Act - tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end) + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end, stream=False) # Assert assert len(tracks) == 1 @@ -379,7 +379,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Act tracks = lastfm_user.get_recent_tracks( - time_from=utc_start, time_to=utc_end, limit=None + time_from=utc_start, time_to=utc_end, limit=None, stream=False ) # Assert @@ -449,7 +449,7 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("bbc6music") # Act - scrobbles = user.get_track_scrobbles(artist, title) + scrobbles = user.get_track_scrobbles(artist, title, stream=False) # Assert assert len(scrobbles) > 0 @@ -463,9 +463,9 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("bbc6music") # Act - result1 = user.get_track_scrobbles(artist, title, cacheable=False) - result2 = user.get_track_scrobbles(artist, title, cacheable=True) - result3 = user.get_track_scrobbles(artist, title) + result1 = user.get_track_scrobbles(artist, title, cacheable=False, stream=False) + result2 = list(user.get_track_scrobbles(artist, title, cacheable=True)) + result3 = list(user.get_track_scrobbles(artist, title)) # Assert self.helper_validate_results(result1, result2, result3) @@ -480,4 +480,4 @@ class TestPyLastUser(TestPyLastWithLastFm): match="Deprecated - This type of request is no longer supported", ): warnings.filterwarnings("ignore", category=DeprecationWarning) - lastfm_user.get_artist_tracks(artist="Test Artist") + lastfm_user.get_artist_tracks(artist="Test Artist", stream=False) From 11d955dd89d1597110bf6752016682451b267541 Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Sun, 12 Jul 2020 12:21:40 +0200 Subject: [PATCH 576/768] Now playing shouldn't count as a recently played track --- src/pylast/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index f3a61f6..938cc7b 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2385,6 +2385,7 @@ class User(_BaseObject, _Chartable): if time_to: params["to"] = time_to + track_count = 0 for track_node in _collect_nodes( limit + 1 if limit else None, self, @@ -2396,7 +2397,10 @@ class User(_BaseObject, _Chartable): if track_node.hasAttribute("nowplaying"): continue # to prevent the now playing track from sneaking in + if limit and track_count >= limit: + break yield self._extract_played_track(track_node=track_node) + track_count += 1 return _get_recent_tracks() if stream else list(_get_recent_tracks()) From 99fb7cd7a5d2960420093346637085a54e72e35d Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Sun, 12 Jul 2020 12:23:32 +0200 Subject: [PATCH 577/768] Added a parameter to choose whether to include now playing or not --- src/pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 938cc7b..b0a49c5 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2355,7 +2355,7 @@ class User(_BaseObject, _Chartable): return Track(artist, title, self.network, self.name, info=info) - def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None, stream=True): + def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None, stream=True, now_playing=False): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. @@ -2394,7 +2394,7 @@ class User(_BaseObject, _Chartable): params, stream=stream ): - if track_node.hasAttribute("nowplaying"): + if track_node.hasAttribute("nowplaying") and not now_playing: continue # to prevent the now playing track from sneaking in if limit and track_count >= limit: From 15672922a7a4be69829d746f48215347543c98ca Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Sun, 12 Jul 2020 12:24:06 +0200 Subject: [PATCH 578/768] General code improvements --- src/pylast/__init__.py | 74 ++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index b0a49c5..48baf68 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -28,12 +28,12 @@ import ssl import tempfile import time import warnings -import xml.dom from http.client import HTTPSConnection from urllib.parse import quote_plus -from xml.dom import Node, minidom import pkg_resources +import xml.dom +from xml.dom import Node, minidom __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2020 hugovk, 2017 Mice Pápai" @@ -424,7 +424,9 @@ class _Network: """ if not file_path: - file_path = tempfile.mktemp(prefix="pylast_tmp_") + file = tempfile.TemporaryFile(prefix="pylast_tmp_") + file.close() + file_path = file.name self.cache_backend = _ShelfCacheBackend(file_path) @@ -668,8 +670,7 @@ class LastFMNetwork(_Network): password_hash="", token="", ): - _Network.__init__( - self, + super().__init__( name="Last.fm", homepage="https://www.last.fm", ws_server=("ws.audioscrobbler.com", "/2.0/"), @@ -736,8 +737,7 @@ class LibreFMNetwork(_Network): self, api_key="", api_secret="", session_key="", username="", password_hash="" ): - _Network.__init__( - self, + super().__init__( name="Libre.fm", homepage="https://libre.fm", ws_server=("libre.fm", "/2.0/"), @@ -1205,11 +1205,11 @@ class _BaseObject: return _extract(node, section) -class _Chartable: +class _Chartable(_BaseObject): """Common functions for classes with charts.""" - def __init__(self, ws_prefix): - self.ws_prefix = ws_prefix # TODO move to _BaseObject? + def __init__(self, network, ws_prefix): + super().__init__(network=network, ws_prefix=ws_prefix) def get_weekly_chart_dates(self): """Returns a list of From and To tuples for the available charts.""" @@ -1276,11 +1276,11 @@ class _Chartable: return seq -class _Taggable: +class _Taggable(_BaseObject): """Common functions for classes with tags.""" - def __init__(self, ws_prefix): - self.ws_prefix = ws_prefix # TODO move to _BaseObject + def __init__(self, network, ws_prefix): + super().__init__(network=network, ws_prefix=ws_prefix) def add_tags(self, tags): """Adds one or several tags. @@ -1385,9 +1385,9 @@ class _Taggable: for element in elements: tag_name = _extract(element, "name") - tagcount = _extract(element, "count") + tag_count = _extract(element, "count") - seq.append(TopItem(Tag(tag_name, self.network), tagcount)) + seq.append(TopItem(Tag(tag_name, self.network), tag_count)) if limit: seq = seq[:limit] @@ -1463,7 +1463,7 @@ class NetworkError(Exception): return "NetworkError: %s" % str(self.underlying_error) -class _Opus(_BaseObject, _Taggable): +class _Opus(_Taggable): """An album or track.""" artist = None @@ -1484,8 +1484,7 @@ class _Opus(_BaseObject, _Taggable): if info is None: info = {} - _BaseObject.__init__(self, network, ws_prefix) - _Taggable.__init__(self, ws_prefix) + super().__init__(network=network, ws_prefix=ws_prefix) if isinstance(artist, Artist): self.artist = artist @@ -1653,7 +1652,7 @@ class Album(_Opus): } -class Artist(_BaseObject, _Taggable): +class Artist(_Taggable): """An artist.""" name = None @@ -1670,8 +1669,7 @@ class Artist(_BaseObject, _Taggable): if info is None: info = {} - _BaseObject.__init__(self, network, "artist") - _Taggable.__init__(self, "artist") + super().__init__(network=network, ws_prefix="artist") self.name = name self.username = username @@ -1883,7 +1881,7 @@ class Country(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, name, network): - _BaseObject.__init__(self, network, "geo") + super().__init__(network=network, ws_prefix="geo") self.name = name @@ -1958,7 +1956,7 @@ class Library(_BaseObject): __hash__ = _BaseObject.__hash__ def __init__(self, user, network): - _BaseObject.__init__(self, network, "library") + super().__init__(network=network, ws_prefix="library") if isinstance(user, User): self.user = user @@ -1999,7 +1997,7 @@ class Library(_BaseObject): return _get_artists() if stream else list(_get_artists()) -class Tag(_BaseObject, _Chartable): +class Tag(_Chartable): """A Last.fm object tag.""" name = None @@ -2007,8 +2005,7 @@ class Tag(_BaseObject, _Chartable): __hash__ = _BaseObject.__hash__ def __init__(self, name, network): - _BaseObject.__init__(self, network, "tag") - _Chartable.__init__(self, "tag") + super().__init__(network=network, ws_prefix="tag") self.name = name @@ -2210,7 +2207,7 @@ class Track(_Opus): } -class User(_BaseObject, _Chartable): +class User(_Chartable): """A Last.fm user.""" name = None @@ -2218,8 +2215,7 @@ class User(_BaseObject, _Chartable): __hash__ = _BaseObject.__hash__ def __init__(self, user_name, network): - _BaseObject.__init__(self, network, "user") - _Chartable.__init__(self, "user") + super().__init__(network=network, ws_prefix="user") self.name = user_name @@ -2619,21 +2615,21 @@ class User(_BaseObject, _Chartable): class AuthenticatedUser(User): def __init__(self, network): - User.__init__(self, network.username, network) + super().__init__(user_name=network.username, network=network) def _get_params(self): return {"user": self.get_name()} - def get_name(self): + def get_name(self, properly_capitalized=False): """Returns the name of the authenticated user.""" - return self.name + return super().get_name(properly_capitalized=properly_capitalized) class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" def __init__(self, ws_prefix, search_terms, network): - _BaseObject.__init__(self, network, ws_prefix) + super().__init__(network, ws_prefix) self._ws_prefix = ws_prefix self.search_terms = search_terms @@ -2673,8 +2669,7 @@ class AlbumSearch(_Search): """Search for an album by name.""" def __init__(self, album_name, network): - - _Search.__init__(self, "album", {"album": album_name}, network) + super().__init__(ws_prefix="album", search_terms={"album": album_name}, network=network) def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" @@ -2699,7 +2694,7 @@ class ArtistSearch(_Search): """Search for an artist by artist name.""" def __init__(self, artist_name, network): - _Search.__init__(self, "artist", {"artist": artist_name}, network) + super().__init__(ws_prefix="artist", search_terms={"artist": artist_name}, network=network) def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" @@ -2726,10 +2721,7 @@ class TrackSearch(_Search): """ def __init__(self, artist_name, track_title, network): - - _Search.__init__( - self, "track", {"track": track_title, "artist": artist_name}, network - ) + super().__init__(ws_prefix="track", search_terms={"track": track_title, "artist": artist_name}, network=network) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" @@ -2928,8 +2920,6 @@ def _number(string): if not string: return 0 - elif string == "": - return 0 else: try: return int(string) From 52abbba2bdc4c193e2c4b7618d98ea3c0346b7a0 Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Sun, 12 Jul 2020 13:13:22 +0200 Subject: [PATCH 579/768] tox lint changes --- src/pylast/__init__.py | 94 +++++++++++++++++++++++++++++++++--------- tests/test_artist.py | 2 +- tests/test_network.py | 2 +- tests/test_pylast.py | 3 +- tests/test_track.py | 2 +- tests/test_user.py | 24 ++++++++++- 6 files changed, 100 insertions(+), 27 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 48baf68..8b4cdd5 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -28,12 +28,12 @@ import ssl import tempfile import time import warnings +import xml.dom from http.client import HTTPSConnection from urllib.parse import quote_plus +from xml.dom import Node, minidom import pkg_resources -import xml.dom -from xml.dom import Node, minidom __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2020 hugovk, 2017 Mice Pápai" @@ -1149,13 +1149,20 @@ class _BaseObject: return first_child.wholeText.strip() - def _get_things(self, method, thing, thing_type, params=None, cacheable=True, stream=False): + def _get_things( + self, method, thing, thing_type, params=None, cacheable=True, stream=False + ): """Returns a list of the most played thing_types by this thing.""" def _stream_get_things(): limit = params.get("limit", 1) nodes = _collect_nodes( - limit, self, self.ws_prefix + "." + method, cacheable, params, stream=stream, + limit, + self, + self.ws_prefix + "." + method, + cacheable, + params, + stream=stream, ) for node in nodes: title = _extract(node, "name") @@ -1840,7 +1847,9 @@ class Artist(_Taggable): if limit: params["limit"] = limit - return self._get_things("getTopAlbums", "album", Album, params, cacheable, stream=stream) + return self._get_things( + "getTopAlbums", "album", Album, params, cacheable, stream=stream + ) def get_top_tracks(self, limit=None, cacheable=True, stream=True): """Returns a list of the most played Tracks by this artist.""" @@ -1848,7 +1857,9 @@ class Artist(_Taggable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) + return self._get_things( + "getTopTracks", "track", Track, params, cacheable, stream=stream + ) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. @@ -1922,7 +1933,9 @@ class Country(_BaseObject): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) + return self._get_things( + "getTopTracks", "track", Track, params, cacheable, stream=stream + ) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. @@ -2051,7 +2064,9 @@ class Tag(_Chartable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) + return self._get_things( + "getTopTracks", "track", Track, params, cacheable, stream=stream + ) def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" @@ -2244,7 +2259,9 @@ class User(_Chartable): date = _extract(track_node, "date") album = _extract(track_node, "album") timestamp = track_node.getElementsByTagName("date")[0].getAttribute("uts") - return PlayedTrack(Track(track_artist, title, self.network), album, date, timestamp) + return PlayedTrack( + Track(track_artist, title, self.network), album, date, timestamp + ) def get_name(self, properly_capitalized=False): """Returns the user name.""" @@ -2276,7 +2293,12 @@ class User(_Chartable): def _get_artist_tracks(): for track_node in _collect_nodes( - None, self, self.ws_prefix + ".getArtistTracks", cacheable, params, stream=stream, + None, + self, + self.ws_prefix + ".getArtistTracks", + cacheable, + params, + stream=stream, ): yield self._extract_played_track(track_node=track_node) @@ -2311,7 +2333,12 @@ class User(_Chartable): params["limit"] = limit for track in _collect_nodes( - limit, self, self.ws_prefix + ".getLovedTracks", cacheable, params, stream=stream + limit, + self, + self.ws_prefix + ".getLovedTracks", + cacheable, + params, + stream=stream, ): try: artist = _extract(track, "name", 1) @@ -2351,7 +2378,15 @@ class User(_Chartable): return Track(artist, title, self.network, self.name, info=info) - def get_recent_tracks(self, limit=10, cacheable=True, time_from=None, time_to=None, stream=True, now_playing=False): + def get_recent_tracks( + self, + limit=10, + cacheable=True, + time_from=None, + time_to=None, + stream=True, + now_playing=False, + ): """ Returns this user's played track as a sequence of PlayedTrack objects in reverse order of playtime, all the way back to the first track. @@ -2388,7 +2423,7 @@ class User(_Chartable): self.ws_prefix + ".getRecentTracks", cacheable, params, - stream=stream + stream=stream, ): if track_node.hasAttribute("nowplaying") and not now_playing: continue # to prevent the now playing track from sneaking in @@ -2537,7 +2572,9 @@ class User(_Chartable): return seq - def get_top_tracks(self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=True): + def get_top_tracks( + self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=True + ): """Returns the top tracks played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -2553,7 +2590,9 @@ class User(_Chartable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable, stream=stream) + return self._get_things( + "getTopTracks", "track", Track, params, cacheable, stream=stream + ) def get_track_scrobbles(self, artist, track, cacheable=False, stream=True): """ @@ -2566,7 +2605,12 @@ class User(_Chartable): def _get_track_scrobbles(): for track_node in _collect_nodes( - None, self, self.ws_prefix + ".getTrackScrobbles", cacheable, params, stream=stream + None, + self, + self.ws_prefix + ".getTrackScrobbles", + cacheable, + params, + stream=stream, ): yield self._extract_played_track(track_node) @@ -2669,7 +2713,9 @@ class AlbumSearch(_Search): """Search for an album by name.""" def __init__(self, album_name, network): - super().__init__(ws_prefix="album", search_terms={"album": album_name}, network=network) + super().__init__( + ws_prefix="album", search_terms={"album": album_name}, network=network + ) def get_next_page(self): """Returns the next page of results as a sequence of Album objects.""" @@ -2694,7 +2740,9 @@ class ArtistSearch(_Search): """Search for an artist by artist name.""" def __init__(self, artist_name, network): - super().__init__(ws_prefix="artist", search_terms={"artist": artist_name}, network=network) + super().__init__( + ws_prefix="artist", search_terms={"artist": artist_name}, network=network + ) def get_next_page(self): """Returns the next page of results as a sequence of Artist objects.""" @@ -2721,7 +2769,11 @@ class TrackSearch(_Search): """ def __init__(self, artist_name, track_title, network): - super().__init__(ws_prefix="track", search_terms={"track": track_title, "artist": artist_name}, network=network) + super().__init__( + ws_prefix="track", + search_terms={"track": track_title, "artist": artist_name}, + network=network, + ) def get_next_page(self): """Returns the next page of results as a sequence of Track objects.""" @@ -2811,7 +2863,9 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=Fa main = doc.documentElement.childNodes[0] if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"): - total_pages = _number(main.getAttribute("totalPages") or main.getAttribute("totalpages")) + total_pages = _number( + main.getAttribute("totalPages") or main.getAttribute("totalpages") + ) else: raise Exception("No total pages attribute") diff --git a/tests/test_artist.py b/tests/test_artist.py index 8f9a97e..befa778 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,9 +2,9 @@ """ Integration (not unit) tests for pylast.py """ +import pylast import pytest -import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_network.py b/tests/test_network.py index 7c2e68d..bdf9435 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,9 +5,9 @@ Integration (not unit) tests for pylast.py import re import time +import pylast import pytest -import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 20d1604..b17fc0c 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -6,11 +6,10 @@ import os import sys import time +import pylast import pytest from flaky import flaky -import pylast - WRITE_TEST = sys.version_info[:2] == (3, 8) diff --git a/tests/test_track.py b/tests/test_track.py index 8ab6faa..fe8eb83 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -4,9 +4,9 @@ Integration (not unit) tests for pylast.py """ import time +import pylast import pytest -import pylast from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_user.py b/tests/test_user.py index 5415cc8..ddf1509 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -4,13 +4,14 @@ Integration (not unit) tests for pylast.py """ import calendar import datetime as dt +import inspect import os import re import warnings +import pylast import pytest -import pylast from .test_pylast import TestPyLastWithLastFm @@ -361,7 +362,9 @@ class TestPyLastUser(TestPyLastWithLastFm): utc_end = calendar.timegm(end.utctimetuple()) # Act - tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end, stream=False) + tracks = lastfm_user.get_recent_tracks( + time_from=utc_start, time_to=utc_end, stream=False + ) # Assert assert len(tracks) == 1 @@ -387,6 +390,23 @@ class TestPyLastUser(TestPyLastWithLastFm): assert str(tracks[0].track.artist) == "Seun Kuti & Egypt 80" assert str(tracks[0].track.title) == "Struggles Sounds" + def test_get_recent_tracks_is_streamable(self): + # Arrange + lastfm_user = self.network.get_user("bbc6music") + start = dt.datetime(2020, 2, 15, 15, 00) + end = dt.datetime(2020, 2, 15, 15, 40) + + utc_start = calendar.timegm(start.utctimetuple()) + utc_end = calendar.timegm(end.utctimetuple()) + + # Act + tracks = lastfm_user.get_recent_tracks( + time_from=utc_start, time_to=utc_end, limit=None, stream=True + ) + + # Assert + assert inspect.isgenerator(tracks) + def test_get_playcount(self): # Arrange user = self.network.get_user("RJ") From a769b611f029ffb59c4b76e8bd31eaa5fbea384b Mon Sep 17 00:00:00 2001 From: kvanzuijlen Date: Tue, 14 Jul 2020 03:17:27 +0200 Subject: [PATCH 580/768] Added -e argument for editable installs and added quotes for some shells Co-authored-by: Hugo van Kemenade --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ec2e8f..0559b64 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE To run all unit and integration tests: ```sh -pip install .[tests] +pip install -e ".[tests]" pytest ``` From 136b7f1cef9296c985b522ff08c8a66264b3e3e7 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Tue, 14 Jul 2020 03:42:44 +0200 Subject: [PATCH 581/768] Made stream=False the default instead of stream=True --- src/pylast/__init__.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 8b4cdd5..c4f3541 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1841,7 +1841,7 @@ class Artist(_Taggable): return artists - def get_top_albums(self, limit=None, cacheable=True, stream=True): + def get_top_albums(self, limit=None, cacheable=True, stream=False): """Returns a list of the top albums.""" params = self._get_params() if limit: @@ -1851,7 +1851,7 @@ class Artist(_Taggable): "getTopAlbums", "album", Album, params, cacheable, stream=stream ) - def get_top_tracks(self, limit=None, cacheable=True, stream=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=False): """Returns a list of the most played Tracks by this artist.""" params = self._get_params() if limit: @@ -1927,7 +1927,7 @@ class Country(_BaseObject): return _extract_top_artists(doc, self) - def get_top_tracks(self, limit=None, cacheable=True, stream=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=False): """Returns a sequence of the most played tracks""" params = self._get_params() if limit: @@ -1990,7 +1990,7 @@ class Library(_BaseObject): """Returns the user who owns this library.""" return self.user - def get_artists(self, limit=50, cacheable=True, stream=True): + def get_artists(self, limit=50, cacheable=True, stream=False): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) @@ -2058,7 +2058,7 @@ class Tag(_Chartable): return _extract_top_albums(doc, self.network) - def get_top_tracks(self, limit=None, cacheable=True, stream=True): + def get_top_tracks(self, limit=None, cacheable=True, stream=False): """Returns a list of the most played Tracks for this tag.""" params = self._get_params() if limit: @@ -2273,7 +2273,7 @@ class User(_Chartable): return self.name - def get_artist_tracks(self, artist, cacheable=False, stream=True): + def get_artist_tracks(self, artist, cacheable=False, stream=False): """ Deprecated by Last.fm. Get a list of tracks by a given artist scrobbled by this user, @@ -2304,7 +2304,7 @@ class User(_Chartable): return _get_artist_tracks() if stream else list(_get_artist_tracks()) - def get_friends(self, limit=50, cacheable=False, stream=True): + def get_friends(self, limit=50, cacheable=False, stream=False): """Returns a list of the user's friends. """ def _get_friends(): @@ -2315,13 +2315,13 @@ class User(_Chartable): return _get_friends() if stream else list(_get_friends()) - def get_loved_tracks(self, limit=50, cacheable=True, stream=True): + def get_loved_tracks(self, limit=50, cacheable=True, stream=False): """ Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. - If stream=True, it will yield tracks as soon as a page has been retrieved. + If stream=False, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. @@ -2384,7 +2384,7 @@ class User(_Chartable): cacheable=True, time_from=None, time_to=None, - stream=True, + stream=False, now_playing=False, ): """ @@ -2573,7 +2573,7 @@ class User(_Chartable): return seq def get_top_tracks( - self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=True + self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=False ): """Returns the top tracks played by a user. * period: The period of time. Possible values: @@ -2594,7 +2594,7 @@ class User(_Chartable): "getTopTracks", "track", Track, params, cacheable, stream=stream ) - def get_track_scrobbles(self, artist, track, cacheable=False, stream=True): + def get_track_scrobbles(self, artist, track, cacheable=False, stream=False): """ Get a list of this user's scrobbles of this artist's track, including scrobble time. From 574476e44c394bf0f2268d911cc7f847d7c1db51 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Aug 2020 10:36:22 +0300 Subject: [PATCH 582/768] Include tests in coverage https://nedbatchelder.com/blog/202008/you_should_include_your_tests_in_coverage.html --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 87367e0..0a61ca1 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ setenv = PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:} PYLAST_API_KEY={env:PYLAST_API_KEY:} PYLAST_API_SECRET={env:PYLAST_API_SECRET:} -commands = pytest -v -s -W all --cov pylast --cov-report term-missing --random-order {posargs} +commands = pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs} [testenv:venv] deps = ipdb From 66f5ace9173a683d751df97e3b6440ca1e1fd434 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Aug 2020 10:38:42 +0300 Subject: [PATCH 583/768] pre-commit autoupdate --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da1e286..4ddbb33 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.6.1 + rev: v2.7.2 hooks: - id: pyupgrade args: ["--py3-plus"] @@ -26,17 +26,17 @@ repos: - id: seed-isort-config - repo: https://github.com/timothycrosley/isort - rev: 4.3.21 + rev: 5.4.2 hooks: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.5.1 + rev: v1.6.0 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.1.0 + rev: v3.2.0 hooks: - id: check-merge-conflict - id: check-yaml From 48f4be0bcf26b277a724e41e80b5fa2941f9bfe2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 22 Aug 2020 11:45:33 +0300 Subject: [PATCH 584/768] Rewrite and add pragmas to improve coverage of non-runnable test code --- tests/test_artist.py | 28 ++++++---------------------- tests/test_pylast.py | 2 +- tests/test_user.py | 11 ++++++++--- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 8250f5b..802a5e2 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -153,11 +153,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags = artist.get_tags() assert len(tags) > 0 - found = False - for tag in tags: - if tag.name == "testing": - found = True - break + found = any(tag.name == "testing" for tag in tags) assert found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") @@ -172,11 +168,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags = artist.get_tags() - found = False - for tag in tags: - if tag.name == "testing": - found = True - break + found = any(tag.name == "testing" for tag in tags) assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") @@ -191,11 +183,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags = artist.get_tags() - found = False - for tag in tags: - if tag.name == "testing": - found = True - break + found = any(tag.name == "testing" for tag in tags) assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") @@ -213,12 +201,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert tags_after = artist.get_tags() assert len(tags_after) == len(tags_before) - 2 - found1, found2 = False, False - for tag in tags_after: - if tag.name == "removetag1": - found1 = True - elif tag.name == "removetag2": - found2 = True + found1 = any(tag.name == "removetag1" for tag in tags_after) + found2 = any(tag.name == "removetag2" for tag in tags_after) assert not found1 assert not found2 @@ -308,4 +292,4 @@ class TestPyLastArtist(TestPyLastWithLastFm): playcount = artist.get_userplaycount() # Assert - assert playcount >= 0 + assert playcount >= 0 # whilst xfail: # pragma: no cover diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 83a64ad..730d39d 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -13,7 +13,7 @@ from flaky import flaky WRITE_TEST = sys.version_info[:2] == (3, 8) -def load_secrets(): +def load_secrets(): # pragma: no cover secrets_file = "test_pylast.yaml" if os.path.isfile(secrets_file): import yaml # pip install pyyaml diff --git a/tests/test_user.py b/tests/test_user.py index b0ae898..7f7e2e9 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -68,7 +68,7 @@ class TestPyLastUser(TestPyLastWithLastFm): if int(registered): # Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp assert registered == "1037793040" - else: + else: # pragma: no cover # Old way # Just check date because of timezones assert "2002-11-20 " in registered @@ -192,8 +192,13 @@ class TestPyLastUser(TestPyLastWithLastFm): # Act/Assert self.helper_validate_cacheable(lastfm_user, "get_friends") - self.helper_validate_cacheable(lastfm_user, "get_loved_tracks") - self.helper_validate_cacheable(lastfm_user, "get_recent_tracks") + # no cover whilst xfail: + self.helper_validate_cacheable( # pragma: no cover + lastfm_user, "get_loved_tracks" + ) + self.helper_validate_cacheable( # pragma: no cover + lastfm_user, "get_recent_tracks" + ) def test_user_get_top_tags_with_limit(self): # Arrange From 3129d6052d973bcbf744d962232945c0872fc73f Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 23 Aug 2020 12:11:07 +0300 Subject: [PATCH 585/768] Run xfail tests only once: no point re-running --- tests/test_pylast.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 730d39d..9ed1c7f 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -40,7 +40,12 @@ class PyLastTestCase: assert str.endswith(suffix, start, end) -@flaky(max_runs=3, min_passes=1) +def _no_xfail_rerun_filter(err, name, test, plugin): + for _ in test.iter_markers(name="xfail"): + return False + + +@flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter) class TestPyLastWithLastFm(PyLastTestCase): secrets = None From 99e0cc734a2ae01b16f8c67fc7a773b0d9be8f0f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 31 Aug 2020 23:03:10 +0300 Subject: [PATCH 586/768] Remove W503 from Flake8 ignore list (ignored by default) --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index bf5350d..632c39e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,4 @@ [flake8] -ignore = W503 max_line_length = 88 [tool:isort] From 2d570b97ffea7d979c9209914a814389dd48008e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 11 Sep 2020 23:44:57 +0300 Subject: [PATCH 587/768] Update config --- .github/workflows/lint.yml | 5 ++++- .pre-commit-config.yaml | 15 +++++---------- setup.cfg | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 13f3f43..fd4c7e6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,12 @@ name: Lint on: [push, pull_request] +env: + FORCE_COLOR: 1 + jobs: build: - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ddbb33..9a37fb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,22 +14,17 @@ repos: files: \.pyi?$ types: [] + - repo: https://github.com/PyCQA/isort + rev: 5.5.1 + hooks: + - id: isort + - repo: https://gitlab.com/pycqa/flake8 rev: 3.8.3 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - - repo: https://github.com/asottile/seed-isort-config - rev: v2.2.0 - hooks: - - id: seed-isort-config - - - repo: https://github.com/timothycrosley/isort - rev: 5.4.2 - hooks: - - id: isort - - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.6.0 hooks: diff --git a/setup.cfg b/setup.cfg index 632c39e..191fac9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,4 +2,4 @@ max_line_length = 88 [tool:isort] -known_third_party = flaky,pkg_resources,pylast,pytest,setuptools +profile = black From 0f96fe58b1273fb21589e961a1635858039d4adb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 11 Sep 2020 23:47:42 +0300 Subject: [PATCH 588/768] Format with Black and isort --- .pre-commit-config.yaml | 4 +- src/pylast/__init__.py | 114 ++++++++++++++++++++-------------------- tests/test_artist.py | 3 +- tests/test_librefm.py | 3 +- tests/test_network.py | 3 +- tests/test_pylast.py | 3 +- tests/test_track.py | 3 +- tests/test_user.py | 3 +- tests/unicode_test.py | 3 +- 9 files changed, 73 insertions(+), 66 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a37fb9..58efd85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: ["--py3-plus"] - repo: https://github.com/psf/black - rev: 19.10b0 + rev: 20.8b1 hooks: - id: black args: ["--target-version", "py35"] @@ -15,7 +15,7 @@ repos: types: [] - repo: https://github.com/PyCQA/isort - rev: 5.5.1 + rev: 5.5.2 hooks: - id: isort diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index fb2de22..6e3c226 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -146,29 +146,29 @@ class _Network: token=None, ): """ - name: the name of the network - homepage: the homepage URL - ws_server: the URL of the webservices server - api_key: a provided API_KEY - api_secret: a provided API_SECRET - session_key: a generated session_key or None - username: a username of a valid user - password_hash: the output of pylast.md5(password) where password is - the user's password - domain_names: a dict mapping each DOMAIN_* value to a string domain - name - urls: a dict mapping types to URLs - token: an authentication token to retrieve a session + name: the name of the network + homepage: the homepage URL + ws_server: the URL of the webservices server + api_key: a provided API_KEY + api_secret: a provided API_SECRET + session_key: a generated session_key or None + username: a username of a valid user + password_hash: the output of pylast.md5(password) where password is + the user's password + domain_names: a dict mapping each DOMAIN_* value to a string domain + name + urls: a dict mapping types to URLs + token: an authentication token to retrieve a session - if username and password_hash were provided and not session_key, - session_key will be generated automatically when needed. + if username and password_hash were provided and not session_key, + session_key will be generated automatically when needed. - Either a valid session_key or a combination of username and - password_hash must be present for scrobbling. + Either a valid session_key or a combination of username and + password_hash must be present for scrobbling. - You should use a preconfigured network object through a - get_*_network(...) method instead of creating an object - of this class, unless you know what you're doing. + You should use a preconfigured network object through a + get_*_network(...) method instead of creating an object + of this class, unless you know what you're doing. """ self.name = name @@ -209,56 +209,56 @@ class _Network: def get_artist(self, artist_name): """ - Return an Artist object + Return an Artist object """ return Artist(artist_name, self) def get_track(self, artist, title): """ - Return a Track object + Return a Track object """ return Track(artist, title, self) def get_album(self, artist, title): """ - Return an Album object + Return an Album object """ return Album(artist, title, self) def get_authenticated_user(self): """ - Returns the authenticated user + Returns the authenticated user """ return AuthenticatedUser(self) def get_country(self, country_name): """ - Returns a country object + Returns a country object """ return Country(country_name, self) def get_user(self, username): """ - Returns a user object + Returns a user object """ return User(username, self) def get_tag(self, name): """ - Returns a tag object + Returns a tag object """ return Tag(name, self) def _get_language_domain(self, domain_language): """ - Returns the mapped domain name of the network to a DOMAIN_* value + Returns the mapped domain name of the network to a DOMAIN_* value """ if domain_language in self.domain_names: @@ -271,13 +271,13 @@ class _Network: def _get_ws_auth(self): """ - Returns an (API_KEY, API_SECRET, SESSION_KEY) tuple. + Returns an (API_KEY, API_SECRET, SESSION_KEY) tuple. """ return self.api_key, self.api_secret, self.session_key def _delay_call(self): """ - Makes sure that web service calls are at least 0.2 seconds apart. + Makes sure that web service calls are at least 0.2 seconds apart. """ now = time.time() @@ -1408,31 +1408,31 @@ class WSError(Exception): def get_id(self): """Returns the exception ID, from one of the following: - STATUS_INVALID_SERVICE = 2 - STATUS_INVALID_METHOD = 3 - STATUS_AUTH_FAILED = 4 - STATUS_INVALID_FORMAT = 5 - STATUS_INVALID_PARAMS = 6 - STATUS_INVALID_RESOURCE = 7 - STATUS_OPERATION_FAILED = 8 - STATUS_INVALID_SK = 9 - STATUS_INVALID_API_KEY = 10 - STATUS_OFFLINE = 11 - STATUS_SUBSCRIBERS_ONLY = 12 - STATUS_TOKEN_UNAUTHORIZED = 14 - STATUS_TOKEN_EXPIRED = 15 - STATUS_TEMPORARILY_UNAVAILABLE = 16 - STATUS_LOGIN_REQUIRED = 17 - STATUS_TRIAL_EXPIRED = 18 - STATUS_NOT_ENOUGH_CONTENT = 20 - STATUS_NOT_ENOUGH_MEMBERS = 21 - STATUS_NOT_ENOUGH_FANS = 22 - STATUS_NOT_ENOUGH_NEIGHBOURS = 23 - STATUS_NO_PEAK_RADIO = 24 - STATUS_RADIO_NOT_FOUND = 25 - STATUS_API_KEY_SUSPENDED = 26 - STATUS_DEPRECATED = 27 - STATUS_RATE_LIMIT_EXCEEDED = 29 + STATUS_INVALID_SERVICE = 2 + STATUS_INVALID_METHOD = 3 + STATUS_AUTH_FAILED = 4 + STATUS_INVALID_FORMAT = 5 + STATUS_INVALID_PARAMS = 6 + STATUS_INVALID_RESOURCE = 7 + STATUS_OPERATION_FAILED = 8 + STATUS_INVALID_SK = 9 + STATUS_INVALID_API_KEY = 10 + STATUS_OFFLINE = 11 + STATUS_SUBSCRIBERS_ONLY = 12 + STATUS_TOKEN_UNAUTHORIZED = 14 + STATUS_TOKEN_EXPIRED = 15 + STATUS_TEMPORARILY_UNAVAILABLE = 16 + STATUS_LOGIN_REQUIRED = 17 + STATUS_TRIAL_EXPIRED = 18 + STATUS_NOT_ENOUGH_CONTENT = 20 + STATUS_NOT_ENOUGH_MEMBERS = 21 + STATUS_NOT_ENOUGH_FANS = 22 + STATUS_NOT_ENOUGH_NEIGHBOURS = 23 + STATUS_NO_PEAK_RADIO = 24 + STATUS_RADIO_NOT_FOUND = 25 + STATUS_API_KEY_SUSPENDED = 26 + STATUS_DEPRECATED = 27 + STATUS_RATE_LIMIT_EXCEEDED = 29 """ return self.status @@ -2937,8 +2937,8 @@ def _url_safe(text): def _number(string): """ - Extracts an int from a string. - Returns a 0 if None or an empty string was passed. + Extracts an int from a string. + Returns a 0 if None or an empty string was passed. """ if not string: diff --git a/tests/test_artist.py b/tests/test_artist.py index 802a5e2..309537f 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,9 +2,10 @@ """ Integration (not unit) tests for pylast.py """ -import pylast import pytest +import pylast + from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_librefm.py b/tests/test_librefm.py index cb8ddcc..6b0f3dd 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -2,9 +2,10 @@ """ Integration (not unit) tests for pylast.py """ -import pylast from flaky import flaky +import pylast + from .test_pylast import PyLastTestCase, load_secrets diff --git a/tests/test_network.py b/tests/test_network.py index bad8c54..3416260 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -5,9 +5,10 @@ Integration (not unit) tests for pylast.py import re import time -import pylast import pytest +import pylast + from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 9ed1c7f..789afad 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -6,10 +6,11 @@ import os import sys import time -import pylast import pytest from flaky import flaky +import pylast + WRITE_TEST = sys.version_info[:2] == (3, 8) diff --git a/tests/test_track.py b/tests/test_track.py index 3bfe995..523498e 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -4,9 +4,10 @@ Integration (not unit) tests for pylast.py """ import time -import pylast import pytest +import pylast + from .test_pylast import WRITE_TEST, TestPyLastWithLastFm diff --git a/tests/test_user.py b/tests/test_user.py index 7f7e2e9..2428e69 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -8,9 +8,10 @@ import os import re import warnings -import pylast import pytest +import pylast + from .test_pylast import TestPyLastWithLastFm diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 7efcfea..7b3c271 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,8 +1,9 @@ from unittest import mock -import pylast import pytest +import pylast + def mock_network(): return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", ""))) From 08ff0085058c19723256c4561c61de00f675bbd5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 11 Sep 2020 23:56:15 +0300 Subject: [PATCH 589/768] Drop support for EOL Python 3.5 --- .pre-commit-config.yaml | 4 ++-- .travis.yml | 1 - README.md | 13 +++++++------ setup.py | 3 +-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 58efd85..8f833d2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,13 +3,13 @@ repos: rev: v2.7.2 hooks: - id: pyupgrade - args: ["--py3-plus"] + args: ["--py36-plus"] - repo: https://github.com/psf/black rev: 20.8b1 hooks: - id: black - args: ["--target-version", "py35"] + args: ["--target-version", "py36"] # override until resolved: https://github.com/psf/black/issues/402 files: \.pyi?$ types: [] diff --git a/.travis.yml b/.travis.yml index f904565..688e4e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,6 @@ matrix: - python: 3.8 - python: 3.7 - python: 3.6 - - python: 3.5 - python: 3.9-dev - python: 3.10-dev - python: pypy3 diff --git a/README.md b/README.md index 806c718..6ddcf9f 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,13 @@ Or from requirements.txt: Note: -* pylast 3.0.0+ supports Python 3.5+ ([#265](https://github.com/pylast/pylast/issues/265)) -* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7. -* pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4, 3.5, 3.6. -* pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6. -* pyLast 1.0.0 - 1.6.0 supports Python 2.7, 3.3, 3.4. +* pyLast 4.0.0+ supports Python 3.6+. +* pyLast 3.2.0 - 3.3.0 supports Python 3.5-3.8. +* pyLast 3.0.0 - 3.1.0 supports Python 3.5-3.7. +* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4-3.7. +* pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4-3.6. +* pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3-3.6. +* pyLast 1.0.0 - 1.6.0 supports Python 2.7, 3.3-3.4. * pyLast 0.5 supports Python 2, 3. * pyLast < 0.5 supports Python 2. @@ -49,7 +51,6 @@ Features * Proxy support. * Internal caching support for some web services calls (disabled by default). * Support for other API-compatible networks like Libre.fm. - * Python 3-friendly (Starting from 0.5). Getting started diff --git a/setup.py b/setup.py index 7238518..171283d 100755 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ setup( extras_require={ "tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] }, - python_requires=">=3.5", + python_requires=">=3.6", classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", @@ -35,7 +35,6 @@ setup( "Topic :: Multimedia :: Sound/Audio", "Topic :: Software Development :: Libraries :: Python Modules", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", From bf753884c4b125d4faef8fa7ccce8f00f3b5569b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 12 Sep 2020 00:13:19 +0300 Subject: [PATCH 590/768] Add support for Python 3.9 --- README.md | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ddcf9f..ceed792 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Or from requirements.txt: Note: -* pyLast 4.0.0+ supports Python 3.6+. +* pyLast 4.0.0+ supports Python 3.6-3.9. * pyLast 3.2.0 - 3.3.0 supports Python 3.5-3.8. * pyLast 3.0.0 - 3.1.0 supports Python 3.5-3.7. * pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4-3.7. diff --git a/setup.py b/setup.py index 171283d..a2d891f 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ setup( "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", From 85f58472a332156f14ab7f836eed42259ae61547 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 12 Sep 2020 00:21:12 +0300 Subject: [PATCH 591/768] Remove deprecated Artist.get_cover_image, User.get_artist_tracks and STATUS_TOKEN_ERROR --- src/pylast/__init__.py | 65 +----------------------------------------- tests/test_artist.py | 4 --- tests/test_user.py | 13 --------- 3 files changed, 1 insertion(+), 81 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 6e3c226..e02c764 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -27,7 +27,6 @@ import shelve import ssl import tempfile import time -import warnings import xml.dom from http.client import HTTPSConnection from urllib.parse import quote_plus @@ -49,9 +48,7 @@ STATUS_AUTH_FAILED = 4 STATUS_INVALID_FORMAT = 5 STATUS_INVALID_PARAMS = 6 STATUS_INVALID_RESOURCE = 7 -# DeprecationWarning: STATUS_TOKEN_ERROR is deprecated and will be -# removed in a future version. Use STATUS_OPERATION_FAILED instead. -STATUS_OPERATION_FAILED = STATUS_TOKEN_ERROR = 8 +STATUS_OPERATION_FAILED = 8 STATUS_INVALID_SK = 9 STATUS_INVALID_API_KEY = 10 STATUS_OFFLINE = 11 @@ -1715,32 +1712,6 @@ class Artist(_BaseObject, _Taggable): return _extract(self._request(self.ws_prefix + ".getCorrection"), "name") - def get_cover_image(self, size=SIZE_EXTRA_LARGE): - """ - Returns a URI to the cover image - size can be one of: - SIZE_MEGA - SIZE_EXTRA_LARGE - SIZE_LARGE - SIZE_MEDIUM - SIZE_SMALL - """ - - warnings.warn( - "Artist.get_cover_image is deprecated and will be removed in a future " - "version. In the meantime, only default star images are available. " - "See https://github.com/pylast/pylast/issues/317 and " - "https://support.last.fm/t/api-announcement/202", - DeprecationWarning, - stacklevel=2, - ) - - if "image" not in self.info: - self.info["image"] = _extract_all( - self._request(self.ws_prefix + ".getInfo", cacheable=True), "image" - ) - return self.info["image"][size] - def get_playcount(self): """Returns the number of plays on the network.""" @@ -2251,40 +2222,6 @@ class User(_BaseObject, _Chartable): return self.name - def get_artist_tracks(self, artist, cacheable=False): - """ - Deprecated by Last.fm. - Get a list of tracks by a given artist scrobbled by this user, - including scrobble time. - """ - - warnings.warn( - "User.get_artist_tracks is deprecated and will be removed in a future " - "version. User.get_track_scrobbles is a partial replacement. " - "See https://github.com/pylast/pylast/issues/298", - DeprecationWarning, - stacklevel=2, - ) - - params = self._get_params() - params["artist"] = artist - - seq = [] - for track in _collect_nodes( - None, self, self.ws_prefix + ".getArtistTracks", cacheable, params - ): - title = _extract(track, "name") - artist = _extract(track, "artist") - date = _extract(track, "date") - album = _extract(track, "album") - timestamp = track.getElementsByTagName("date")[0].getAttribute("uts") - - seq.append( - PlayedTrack(Track(artist, title, self.network), album, date, timestamp) - ) - - return seq - def get_friends(self, limit=50, cacheable=False): """Returns a list of the user's friends. """ diff --git a/tests/test_artist.py b/tests/test_artist.py index 309537f..679d917 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -241,16 +241,12 @@ class TestPyLastArtist(TestPyLastWithLastFm): url = artist1.get_url() mbid = artist1.get_mbid() - with pytest.warns(DeprecationWarning): - image = artist1.get_cover_image() - playcount = artist1.get_playcount() streamable = artist1.is_streamable() name = artist1.get_name(properly_capitalized=False) name_cap = artist1.get_name(properly_capitalized=True) # Assert - assert "https" in image assert playcount > 1 assert artist1 != artist2 assert name.lower() == name_cap.lower() diff --git a/tests/test_user.py b/tests/test_user.py index 2428e69..99766dc 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -6,7 +6,6 @@ import calendar import datetime as dt import os import re -import warnings import pytest @@ -475,15 +474,3 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_validate_results(result1, result2, result3) - - def test_get_artist_tracks_deprecated(self): - # Arrange - lastfm_user = self.network.get_user(self.username) - - # Act / Assert - with warnings.catch_warnings(), pytest.raises( - pylast.WSError, - match="Deprecated - This type of request is no longer supported", - ): - warnings.filterwarnings("ignore", category=DeprecationWarning) - lastfm_user.get_artist_tracks(artist="Test Artist") From 6da916e78db26c7fdcd451d94c7dcf33a3a0ca9b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 7 Oct 2020 20:55:58 +0300 Subject: [PATCH 592/768] tox-ini-fmt --- .pre-commit-config.yaml | 9 +++++++-- tox.ini | 32 ++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8f833d2..c8a441a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,12 +15,12 @@ repos: types: [] - repo: https://github.com/PyCQA/isort - rev: 5.5.2 + rev: 5.5.4 hooks: - id: isort - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.3 + rev: 3.8.4 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -35,3 +35,8 @@ repos: hooks: - id: check-merge-conflict - id: check-yaml + + - repo: https://github.com/tox-dev/tox-ini-fmt + rev: 0.5.0 + hooks: + - id: tox-ini-fmt diff --git a/tox.ini b/tox.ini index 0a61ca1..331d4ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,29 @@ [tox] -envlist = py{36, 37, 38, 39, 310, py3} +envlist = + py{py3, 310, 39, 38, 37, 36} [testenv] -extras = tests setenv = - PYLAST_USERNAME={env:PYLAST_USERNAME:} - PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:} - PYLAST_API_KEY={env:PYLAST_API_KEY:} - PYLAST_API_SECRET={env:PYLAST_API_SECRET:} -commands = pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs} + PYLAST_API_KEY = {env:PYLAST_API_KEY:} + PYLAST_API_SECRET = {env:PYLAST_API_SECRET:} + PYLAST_PASSWORD_HASH = {env:PYLAST_PASSWORD_HASH:} + PYLAST_USERNAME = {env:PYLAST_USERNAME:} +extras = + tests +commands = + pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs} [testenv:venv] -deps = ipdb -commands = {posargs} +deps = + ipdb +commands = + {posargs} [testenv:lint] -deps = pre-commit -commands = pre-commit run --all-files --show-diff-on-failure +passenv = + PRE_COMMIT_COLOR skip_install = true -passenv = PRE_COMMIT_COLOR +deps = + pre-commit +commands = + pre-commit run --all-files --show-diff-on-failure From e888739b1480be76cdccc7b5b842b068c3fd09a3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 7 Oct 2020 21:02:08 +0300 Subject: [PATCH 593/768] Add 4.0.0 to changelog --- CHANGELOG.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d7042..64d7765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2020-10-07 +## Added + +* Add support for Python 3.9 (#347) @hugovk + +## Removed + +* Remove deprecated Artist.get_cover_image, User.get_artist_tracks and STATUS_TOKEN_ERROR (#348) @hugovk +* Drop support for EOL Python 3.5 (#346) @hugovk + ## [3.3.0] - 2020-06-25 ### Added @@ -86,10 +96,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) -[3.3.0]: https://github.com/pylast/pylast/compare/v3.2.1...3.3.0 -[3.2.1]: https://github.com/pylast/pylast/compare/v3.2.0...3.2.1 -[3.2.0]: https://github.com/pylast/pylast/compare/v3.1.0...3.2.0 -[3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0 +[4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0 +[3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0 +[3.2.1]: https://github.com/pylast/pylast/compare/3.2.0...3.2.1 +[3.2.0]: https://github.com/pylast/pylast/compare/3.1.0...3.2.0 +[3.1.0]: https://github.com/pylast/pylast/compare/3.0.0...3.1.0 [3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0 [2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0 [#265]: https://github.com/pylast/pylast/issues/265 @@ -105,3 +116,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#311]: https://github.com/pylast/pylast/issues/311 [#312]: https://github.com/pylast/pylast/issues/312 [#316]: https://github.com/pylast/pylast/issues/316 +[#346]: https://github.com/pylast/pylast/issues/346 +[#347]: https://github.com/pylast/pylast/issues/347 +[#348]: https://github.com/pylast/pylast/issues/348 From b5a9617cdfa959a0fd1ed8a832089e503df0b57f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 7 Oct 2020 21:26:42 +0300 Subject: [PATCH 594/768] 3.9-dev first --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 688e4e4..77e8c45 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,10 +20,10 @@ matrix: include: - python: 3.8 env: TOXENV=lint + - python: 3.9-dev - python: 3.8 - python: 3.7 - python: 3.6 - - python: 3.9-dev - python: 3.10-dev - python: pypy3 From 49e2831cf607f6f7247ba63f03b51a495695e3c6 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 7 Oct 2020 21:27:17 +0300 Subject: [PATCH 595/768] Code formatting --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d7765..b1c3a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Removed -* Remove deprecated Artist.get_cover_image, User.get_artist_tracks and STATUS_TOKEN_ERROR (#348) @hugovk +* Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and `STATUS_TOKEN_ERROR` (#348) @hugovk * Drop support for EOL Python 3.5 (#346) @hugovk From b6eb1c8bafa4eae494de3a8e652f13e75865720b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 28 Oct 2020 16:17:15 +0200 Subject: [PATCH 596/768] Add Hacktoberfest labels --- .github/labels.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/labels.yml b/.github/labels.yml index 6ea43df..13ae9e0 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -97,6 +97,12 @@ - color: 0366d6 description: "For dependencies" name: dependencies +- color: f4660e + description: "" + name: Hacktoberfest +- color: f4660e + description: "To credit accepted Hacktoberfest PRs" + name: hacktoberfest-accepted - color: fef2c0 description: "" name: test From c218aab4dd16d9b60972f5924ab944ee98481e77 Mon Sep 17 00:00:00 2001 From: sheetalsingala Date: Wed, 28 Oct 2020 12:42:46 -0400 Subject: [PATCH 597/768] Add Python 3.9 final to Travis CI --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 77e8c45..d7865e5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ matrix: include: - python: 3.8 env: TOXENV=lint - - python: 3.9-dev + - python: 3.9 - python: 3.8 - python: 3.7 - python: 3.6 From d7e1d70c34c094be63867bc61b32113f7fe9c651 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 16 Nov 2020 22:41:24 +0200 Subject: [PATCH 598/768] Use pre-commit/action --- .github/workflows/lint.yml | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fd4c7e6..bda0c64 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,39 +2,11 @@ name: Lint on: [push, pull_request] -env: - FORCE_COLOR: 1 - jobs: build: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - - - name: Cache - uses: actions/cache@v2 - with: - path: | - ~/.cache/pip - ~/.cache/pre-commit - key: - lint-v2-${{ hashFiles('**/setup.py') }}-${{ - hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-v2- - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -U tox - - - name: Lint - run: tox -e lint - env: - PRE_COMMIT_COLOR: always + - uses: actions/setup-python@v2 + - uses: pre-commit/action@v2.0.0 From b7e2cce725e2e3e77018bb94ff13c86caad731a5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 16 Nov 2020 22:41:44 +0200 Subject: [PATCH 599/768] Remove Travis CI --- .travis.yml | 66 ----------------------------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d7865e5..0000000 --- a/.travis.yml +++ /dev/null @@ -1,66 +0,0 @@ -language: python -cache: - pip: true - directories: - - $HOME/.cache/pre-commit - -env: - global: - - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= - - secure: gDWNEYA1EUv4G230/KzcTgcmEST0nf2FeW/z/prsoQBu+TWw1rKKSJAJeMLvuI1z4aYqqNYdmqjWyNhhVK3p5wmFP2lxbhaBT1jDsxxFpePc0nUkdAQOOD0yBpbBGkqkjjxU34HjTX2NFNEbcM3izVVE9oQmS5r4oFFNJgdL91c= - - secure: RpsZblHFU7a5dnkO/JUgi70RkNJwoUh3jJqVo1oOLjL+lvuAmPXhI8MDk2diUk43X+XCBFBEnm7UCGnjUF+hDnobO4T+VrIFuVJWg3C7iKIT+YWvgG6A+CSeo/P0I0dAeUscTr5z4ylOq3EDx4MFSa8DmoWMmjKTAG1GAeTlY2k= - - secure: T5OKyd5Bs0nZbUr+YICbThC5GrFq/kUjX8FokzCv7NWsYaUWIwEmMXXzoYALoB3A+rAglOx6GABaupoNKKg3tFQyxXphuMKpZ8MasMAMFjFW0d7wsgGy0ylhVwrgoKzDbCQ5FKbohC+9ltLs+kKMCQ0L+MI70a/zTfF4/dVWO/o= - - secure: DxBvGGoIgbAeuuU3A6+J1HBbmUAEvqdmK73etw+yNKDLGvvukgTL33dNCr8CZXLKRRvfhrjU7Q01GUpOTxrVQ9nJgsD55kwx0wPtuBWIF80M2m4SPsiVLlwP/LFYD5JMDTDWjFTlVahma8P7qoLjCc7b/RgigWLidH19snQmjdY= - - secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs= - - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y= - - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= - -matrix: - fast_finish: true - include: - - python: 3.8 - env: TOXENV=lint - - python: 3.9 - - python: 3.8 - - python: 3.7 - - python: 3.6 - - python: 3.10-dev - - python: pypy3 - -install: -- travis_retry pip install -U pip -- travis_retry pip install -U tox-travis - -script: tox - -after_success: - - | - if [ "$TOXENV" != "lint" ]; then - travis_retry pip install -U coveralls && coveralls - travis_retry pip install -U codecov && codecov - fi - -deploy: - - provider: pypi - server: https://test.pypi.org/legacy/ - on: - tags: false - repo: pylast/pylast - branch: master - condition: $TOXENV = lint - user: hugovk - password: - secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" - distributions: sdist --format=gztar bdist_wheel - skip_existing: true - - provider: pypi - on: - tags: true - repo: pylast/pylast - branch: master - condition: $TOXENV = lint - user: hugovk - password: - secure: "OCNT7Sf7TpS6aKuqBXEWxJZjmEpdERTBp/yllOd9xnpFt2ZL96CyKtAhPA8zu5OP58QFEZSafZRfXYJoz78RDrx3gOdRXCFT00vXIMnjVvrAlieNEHCVAT0kRW9lYK1Cf5baHYsOYIs6EZf2fEAhdzvmh83G4Y1Y+FPR9tA6uy8=" - distributions: sdist --format=gztar bdist_wheel - skip_existing: true From 815dc62dcd38091803b068ec0002478529734955 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 16 Nov 2020 22:51:34 +0200 Subject: [PATCH 600/768] Test on GitHub Actions --- .github/workflows/test.yml | 63 ++++++++++++++++++++++++++++++++++++++ tox.ini | 10 +++--- 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..12e6357 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,63 @@ +name: Test + +on: [push, pull_request] + +env: + FORCE_COLOR: 1 + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10-dev", "pypy3"] + os: [ubuntu-20.04] + include: + # Include new variables for Codecov + - { codecov-flag: GHA_Ubuntu2004, os: ubuntu-20.04 } + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.os }}-${{ matrix.python-version }}-v3-${{ + hashFiles('**/setup.py') }} + restore-keys: | + ${{ matrix.os }}-${{ matrix.python-version }}-v3- + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U wheel + python -m pip install -U tox + + - name: Tox tests + shell: bash + run: | + tox -e py + env: + PYLAST_API_KEY: ${{ secrets.PYLAST_API_KEY }} + PYLAST_API_SECRET: ${{ secrets.PYLAST_API_SECRET }} + PYLAST_PASSWORD_HASH: ${{ secrets.PYLAST_PASSWORD_HASH }} + PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + flags: ${{ matrix.codecov-flag }} + name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/tox.ini b/tox.ini index 331d4ef..c19e202 100644 --- a/tox.ini +++ b/tox.ini @@ -3,11 +3,11 @@ envlist = py{py3, 310, 39, 38, 37, 36} [testenv] -setenv = - PYLAST_API_KEY = {env:PYLAST_API_KEY:} - PYLAST_API_SECRET = {env:PYLAST_API_SECRET:} - PYLAST_PASSWORD_HASH = {env:PYLAST_PASSWORD_HASH:} - PYLAST_USERNAME = {env:PYLAST_USERNAME:} +passenv = + PYLAST_API_KEY + PYLAST_API_SECRET + PYLAST_PASSWORD_HASH + PYLAST_USERNAME extras = tests commands = From 5413d636ce107f7291ada9cb80a6ea6a6cbff6a5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 16 Nov 2020 23:45:25 +0200 Subject: [PATCH 601/768] Fix test --- tests/test_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_network.py b/tests/test_network.py index 3416260..702b9a9 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -64,7 +64,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.network.enable_rate_limit() then = time.time() # Make some network call, limit not applied first time - self.network.get_user(self.username) + self.network.get_top_artists() # Make a second network call, limiting should be applied self.network.get_top_artists() now = time.time() From c979b72cf3ba656e6896953f56e20c21fbd93c53 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 17 Nov 2020 17:39:48 +0200 Subject: [PATCH 602/768] Label sync: don't delete existing labels not found in manifest --- .github/workflows/labels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 2303846..e84c13e 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -11,5 +11,7 @@ jobs: steps: - uses: actions/checkout@v2 - uses: micnncim/action-label-syncer@v1 + with: + prune: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From e98f493c39a81f62765c4f6266d9013c4d5e1d74 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 17 Nov 2020 17:40:56 +0200 Subject: [PATCH 603/768] Use version resolver with Release Drafter --- .github/release-drafter.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 402bfbf..c121b37 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,5 +1,5 @@ -name-template: "$NEXT_PATCH_VERSION" -tag-template: "$NEXT_PATCH_VERSION" +name-template: "Release $RESOLVED_VERSION" +tag-template: "$RESOLVED_VERSION" categories: - title: "Added" @@ -26,3 +26,20 @@ template: | ## Changes $CHANGES + +version-resolver: + major: + labels: + - "changelog: Removed" + minor: + labels: + - "changelog: Added" + - "changelog: Changed" + - "changelog: Deprecated" + - "enhancement" + + patch: + labels: + - "changelog: Fixed" + - "bug" + default: minor From 0ba17ecfff960f23e19d2dfae656bcd661f3b9e5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 17 Nov 2020 17:46:50 +0200 Subject: [PATCH 604/768] Deploy to TestPyPI on merges to master, to prod PyPI for tags --- .github/workflows/deploy.yml | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..1f65cb8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy + +on: + push: + branches: + - master + release: + types: + - published + +jobs: + build: + if: github.repository == 'pylast/pylast' + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Cache + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: deploy-${{ hashFiles('**/setup.py') }} + restore-keys: | + deploy- + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U setuptools twine wheel + + - name: Build package + run: | + python setup.py --version + python setup.py sdist --format=gztar bdist_wheel + twine check dist/* + + - name: Publish package to PyPI + if: github.event.action == 'published' + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.pypi_password }} + + - name: Publish package to TestPyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.test_pypi_password }} + repository_url: https://test.pypi.org/legacy/ From e4ca881c951148bbcde39719353dd85599711368 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 17 Nov 2020 17:50:28 +0200 Subject: [PATCH 605/768] pre-commit autoupdate --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8a441a..147a7e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.7.2 + rev: v2.7.4 hooks: - id: pyupgrade args: ["--py36-plus"] @@ -15,7 +15,7 @@ repos: types: [] - repo: https://github.com/PyCQA/isort - rev: 5.5.4 + rev: 5.6.4 hooks: - id: isort @@ -26,12 +26,12 @@ repos: additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.6.0 + rev: v1.7.0 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v3.3.0 hooks: - id: check-merge-conflict - id: check-yaml From 9033debfcd756e2c1ff39e8db910d68da777de5f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 17 Nov 2020 17:58:43 +0200 Subject: [PATCH 606/768] Replace Travis CI with GitHub Actions --- .mergify.yml | 2 -- README.md | 5 ++--- RELEASING.md | 7 ++++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index f2aad55..dad8639 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -3,8 +3,6 @@ pull_request_rules: conditions: - label=automerge - status-success=build - - status-success=continuous-integration/travis-ci/pr - - status-success=continuous-integration/travis-ci/push actions: merge: method: merge diff --git a/README.md b/README.md index ceed792..69872b5 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,9 @@ pyLast [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) -[![Build status](https://travis-ci.org/pylast/pylast.svg?branch=master)](https://travis-ci.org/pylast/pylast) +[![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) -[![Coverage (Coveralls)](https://coveralls.io/repos/github/pylast/pylast/badge.svg?branch=master)](https://coveralls.io/github/pylast/pylast?branch=master) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). diff --git a/RELEASING.md b/RELEASING.md index 4224482..7e3cdfc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,8 +1,9 @@ # Release Checklist * [ ] Get master to the appropriate code release state. - [Travis CI](https://travis-ci.org/pylast/pylast) should be running cleanly for + [GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for all merges to master. + [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) * [ ] Edit release draft, adjust text if needed: https://github.com/pylast/pylast/releases @@ -13,8 +14,8 @@ * [ ] Publish release -* [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has - deployed to [PyPI](https://pypi.org/project/pylast/#history) +* [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy) + has deployed to [PyPI](https://pypi.org/project/pylast/#history) * [ ] Check installation: From 08274028ebf1f3c611e61e23440df88334377455 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Dec 2020 14:01:12 +0200 Subject: [PATCH 607/768] Set limit to 50 by default, not 1 --- src/pylast/__init__.py | 2 +- tests/test_artist.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index e02c764..2f527c0 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1149,7 +1149,7 @@ class _BaseObject: def _get_things(self, method, thing, thing_type, params=None, cacheable=True): """Returns a list of the most played thing_types by this thing.""" - limit = params.get("limit", 1) + limit = params.get("limit", 50) seq = [] for node in _collect_nodes( limit, self, self.ws_prefix + "." + method, cacheable, params diff --git a/tests/test_artist.py b/tests/test_artist.py index 679d917..69dafb9 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -131,6 +131,17 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert len(things) == 100 + def test_artist_top_albums_limit_default(self): + # Arrange + # Pick an artist with plenty of plays + artist = self.network.get_top_artists(limit=1)[0].item + + # Act + things = artist.get_top_albums() + + # Assert + assert len(things) == 50 + def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") From a5034c43c043f0ff8caf9463c02b76bff30a4001 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Dec 2020 14:05:52 +0200 Subject: [PATCH 608/768] Refactor with pytest.mark.parametrize --- tests/test_artist.py | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 69dafb9..c99b04e 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -95,41 +95,17 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Album) - def test_artist_top_albums_limit_1(self): + @pytest.mark.parametrize("test_limit", [1, 50, 100]) + def test_artist_top_albums_limit(self, test_limit: int) -> None: # Arrange - limit = 1 # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit) + things = artist.get_top_albums(limit=test_limit) # Assert - assert len(things) == 1 - - def test_artist_top_albums_limit_50(self): - # Arrange - limit = 50 - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - things = artist.get_top_albums(limit=limit) - - # Assert - assert len(things) == 50 - - def test_artist_top_albums_limit_100(self): - # Arrange - limit = 100 - # Pick an artist with plenty of plays - artist = self.network.get_top_artists(limit=1)[0].item - - # Act - things = artist.get_top_albums(limit=limit) - - # Assert - assert len(things) == 100 + assert len(things) == test_limit def test_artist_top_albums_limit_default(self): # Arrange From 23503a72128c93b726324a345a47a1f9f03e6c3a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Dec 2020 14:22:20 +0200 Subject: [PATCH 609/768] Refactor to remove unused parameter --- src/pylast/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 2f527c0..e53d9da 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1146,8 +1146,8 @@ class _BaseObject: return first_child.wholeText.strip() - def _get_things(self, method, thing, thing_type, params=None, cacheable=True): - """Returns a list of the most played thing_types by this thing.""" + def _get_things(self, method, thing_type, params=None, cacheable=True): + """Returns a list of the most played thing_types.""" limit = params.get("limit", 50) seq = [] @@ -1812,7 +1812,7 @@ class Artist(_BaseObject, _Taggable): if limit: params["limit"] = limit - return self._get_things("getTopAlbums", "album", Album, params, cacheable) + return self._get_things("getTopAlbums", Album, params, cacheable) def get_top_tracks(self, limit=None, cacheable=True): """Returns a list of the most played Tracks by this artist.""" @@ -1820,7 +1820,7 @@ class Artist(_BaseObject, _Taggable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. @@ -1894,7 +1894,7 @@ class Country(_BaseObject): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", Track, params, cacheable) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. @@ -2024,7 +2024,7 @@ class Tag(_BaseObject, _Chartable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", Track, params, cacheable) def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" @@ -2498,7 +2498,7 @@ class User(_BaseObject, _Chartable): if limit: params["limit"] = limit - return self._get_things("getTopTracks", "track", Track, params, cacheable) + return self._get_things("getTopTracks", Track, params, cacheable) def get_track_scrobbles(self, artist, track, cacheable=False): """ From 7327b303371a653c71bae3a77254b2166028fe82 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Dec 2020 14:22:41 +0200 Subject: [PATCH 610/768] Refactor to avoid shadowing built-in --- tests/test_pylast.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 789afad..c734d32 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -34,11 +34,11 @@ def load_secrets(): # pragma: no cover class PyLastTestCase: - def assert_startswith(self, str, prefix, start=None, end=None): - assert str.startswith(prefix, start, end) + def assert_startswith(self, s, prefix, start=None, end=None): + assert s.startswith(prefix, start, end) - def assert_endswith(self, str, suffix, start=None, end=None): - assert str.endswith(suffix, start, end) + def assert_endswith(self, s, suffix, start=None, end=None): + assert s.endswith(suffix, start, end) def _no_xfail_rerun_filter(err, name, test, plugin): From 0999501600fd7c3fff68b07aa93d8d894f03ac9a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 29 Dec 2020 21:24:05 +0200 Subject: [PATCH 611/768] Fix comment --- src/pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c4f3541..fc71011 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2321,7 +2321,7 @@ class User(_Chartable): reverse order of their timestamp, all the way back to the first track. If limit==None, it will try to pull all the available data. - If stream=False, it will yield tracks as soon as a page has been retrieved. + If stream=True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a large amount of data. From 8be8c4efb6905ef97e18f2ecd75b95fd9f67e08c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 29 Dec 2020 21:32:29 +0200 Subject: [PATCH 612/768] No need to set param with default --- tests/test_artist.py | 6 +++--- tests/test_network.py | 4 ++-- tests/test_pylast.py | 4 ++-- tests/test_user.py | 22 ++++++++++------------ 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index befa778..e389010 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -78,7 +78,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_tracks(limit=2, stream=False) + things = artist.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -101,7 +101,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit, stream=False) + things = artist.get_top_albums(limit=limit) # Assert assert len(things) == 1 @@ -113,7 +113,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): artist = self.network.get_top_artists(limit=1)[0].item # Act - things = artist.get_top_albums(limit=limit, stream=False) + things = artist.get_top_albums(limit=limit) # Assert assert len(things) == 50 diff --git a/tests/test_network.py b/tests/test_network.py index bdf9435..cebc7c6 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -153,7 +153,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): country = self.network.get_country("Croatia") # Act - things = country.get_top_tracks(limit=2, stream=False) + things = country.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -171,7 +171,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): tag = self.network.get_tag("blues") # Act - things = tag.get_top_tracks(limit=2, stream=False) + things = tag.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index b17fc0c..da5d816 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -94,8 +94,8 @@ class TestPyLastWithLastFm(PyLastTestCase): func = getattr(thing, function_name, None) # Act - result1 = func(limit=1, cacheable=False, stream=False) - result2 = func(limit=1, cacheable=True, stream=False) + result1 = func(limit=1, cacheable=False) + result2 = func(limit=1, cacheable=True) result3 = list(func(limit=1)) # Assert diff --git a/tests/test_user.py b/tests/test_user.py index ddf1509..2b1d8fc 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -143,10 +143,10 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("test-user") # Act/Assert - assert len(user.get_loved_tracks(limit=20, stream=False)) == 20 - assert len(user.get_loved_tracks(limit=100, stream=False)) <= 100 - assert len(user.get_loved_tracks(limit=None, stream=False)) >= 23 - assert len(user.get_loved_tracks(limit=0, stream=False)) >= 23 + assert len(user.get_loved_tracks(limit=20)) == 20 + assert len(user.get_loved_tracks(limit=100)) <= 100 + assert len(user.get_loved_tracks(limit=None)) >= 23 + assert len(user.get_loved_tracks(limit=0)) >= 23 def test_user_is_hashable(self): # Arrange @@ -211,7 +211,7 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user = self.network.get_user("RJ") # Act - things = lastfm_user.get_top_tracks(limit=2, stream=False) + things = lastfm_user.get_top_tracks(limit=2) # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) @@ -362,9 +362,7 @@ class TestPyLastUser(TestPyLastWithLastFm): utc_end = calendar.timegm(end.utctimetuple()) # Act - tracks = lastfm_user.get_recent_tracks( - time_from=utc_start, time_to=utc_end, stream=False - ) + tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end) # Assert assert len(tracks) == 1 @@ -382,7 +380,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Act tracks = lastfm_user.get_recent_tracks( - time_from=utc_start, time_to=utc_end, limit=None, stream=False + time_from=utc_start, time_to=utc_end, limit=None ) # Assert @@ -469,7 +467,7 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("bbc6music") # Act - scrobbles = user.get_track_scrobbles(artist, title, stream=False) + scrobbles = user.get_track_scrobbles(artist, title) # Assert assert len(scrobbles) > 0 @@ -483,7 +481,7 @@ class TestPyLastUser(TestPyLastWithLastFm): user = self.network.get_user("bbc6music") # Act - result1 = user.get_track_scrobbles(artist, title, cacheable=False, stream=False) + result1 = user.get_track_scrobbles(artist, title, cacheable=False) result2 = list(user.get_track_scrobbles(artist, title, cacheable=True)) result3 = list(user.get_track_scrobbles(artist, title)) @@ -500,4 +498,4 @@ class TestPyLastUser(TestPyLastWithLastFm): match="Deprecated - This type of request is no longer supported", ): warnings.filterwarnings("ignore", category=DeprecationWarning) - lastfm_user.get_artist_tracks(artist="Test Artist", stream=False) + lastfm_user.get_artist_tracks(artist="Test Artist") From b992d2613826ef2d2830842751622c058e55d45f Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Tue, 29 Dec 2020 21:19:46 +0100 Subject: [PATCH 613/768] Bugfix for creation of temporary files --- src/pylast/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 1a24622..af426e2 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -421,9 +421,7 @@ class _Network: """ if not file_path: - file = tempfile.TemporaryFile(prefix="pylast_tmp_") - file.close() - file_path = file.name + file_path = tempfile.mkstemp(prefix="pylast_tmp_") self.cache_backend = _ShelfCacheBackend(file_path) From c851b82a1d37e9a3842781b5c3303971af412fe8 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Tue, 29 Dec 2020 22:12:43 +0100 Subject: [PATCH 614/768] Reverted temporary files change --- src/pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index af426e2..05c0361 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -421,7 +421,7 @@ class _Network: """ if not file_path: - file_path = tempfile.mkstemp(prefix="pylast_tmp_") + file_path = tempfile.mktemp(prefix="pylast_tmp_") self.cache_backend = _ShelfCacheBackend(file_path) From bf7cd60774e87817b6a66c541fdbd102df2c59ac Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 30 Dec 2020 13:06:41 +0200 Subject: [PATCH 615/768] Update formatting, versions and links --- README.md | 51 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1cd834a..0495c7b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ pyLast [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) -A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites such as [Libre.fm](https://libre.fm/). +A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites +such as [Libre.fm](https://libre.fm/). Use the pydoc utility for help on usage or see [tests/](tests/) for examples. @@ -18,25 +19,31 @@ Installation Install via pip: - pip install pylast +```sh +python3 -m pip install pylast +``` Install latest development version: - pip install -U git+https://github.com/pylast/pylast +```sh +python3 -m pip install -U git+https://github.com/pylast/pylast +``` Or from requirements.txt: - -e git://github.com/pylast/pylast.git#egg=pylast +```txt +-e git://github.com/pylast/pylast.git#egg=pylast +``` Note: -* pyLast 4.0.0+ supports Python 3.6-3.9. -* pyLast 3.2.0 - 3.3.0 supports Python 3.5-3.8. -* pyLast 3.0.0 - 3.1.0 supports Python 3.5-3.7. -* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4-3.7. -* pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4-3.6. -* pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3-3.6. -* pyLast 1.0.0 - 1.6.0 supports Python 2.7, 3.3-3.4. +* pyLast 4.0+ supports Python 3.6-3.9. +* pyLast 3.2 - 3.3 supports Python 3.5-3.8. +* pyLast 3.0 - 3.1 supports Python 3.5-3.7. +* pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. +* pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6. +* pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6. +* pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4. * pyLast 0.5 supports Python 2, 3. * pyLast < 0.5 supports Python 2. @@ -55,7 +62,10 @@ Features Getting started --------------- -Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm and use it as follows: +Here's some simple code example to get you started. In order to create any object from +pyLast, you need a `Network` object which represents a social music network that is +Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm +and use it as follows: ```python import pylast @@ -85,14 +95,20 @@ track.add_tags(("awesome", "favorite")) # to get more help about anything and see examples of how it works ``` -More examples in hugovk/lastfm-tools and [tests/](tests/). +More examples in +hugovk/lastfm-tools and +[tests/](https://github.com/pylast/pylast/tree/master/tests). Testing ------- -The [tests/](tests/) directory contains integration and unit tests with Last.fm, and plenty of code examples. +The [tests/](https://github.com/pylast/pylast/tree/master/tests) directory contains +integration and unit tests with Last.fm, and plenty of code examples. -For integration tests you need a test account at Last.fm that will become cluttered with test data, and an API key and secret. Either copy [example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out the credentials, or set them as environment variables like: +For integration tests you need a test account at Last.fm that will become cluttered with +test data, and an API key and secret. Either copy +[example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out +the credentials, or set them as environment variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE @@ -102,17 +118,20 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE ``` To run all unit and integration tests: + ```sh -pip install -e ".[tests]" +python3 -m pip install -e ".[tests]" pytest ``` Or run just one test case: + ```sh pytest -k test_scrobble ``` To run with coverage: + ```sh pytest -v --cov pylast --cov-report term-missing coverage report # for command-line report From 6c6627995743a8c8734881ac24c44668ddc1236f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 30 Dec 2020 13:07:03 +0200 Subject: [PATCH 616/768] Blacken docs --- .pre-commit-config.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 147a7e0..9429dc1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,13 @@ repos: files: \.pyi?$ types: [] + - repo: https://github.com/asottile/blacken-docs + rev: v1.9.1 + hooks: + - id: blacken-docs + args: ["--target-version", "py36"] + additional_dependencies: [black==20.8b1] + - repo: https://github.com/PyCQA/isort rev: 5.6.4 hooks: @@ -31,7 +38,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.3.0 + rev: v3.4.0 hooks: - id: check-merge-conflict - id: check-yaml From 3fcf45062d0e320cf186f67785abbe7cc3308462 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 30 Dec 2020 13:09:06 +0200 Subject: [PATCH 617/768] Blacken docs --- .github/ISSUE_TEMPLATE.md | 7 +++++-- README.md | 8 ++++++-- tests/test_pylast.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 1480fa1..5b2dc65 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -12,8 +12,11 @@ Please include **code** that reproduces the issue. -The [best reproductions](https://stackoverflow.com/help/minimal-reproducible-example) are [self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) with minimal dependencies. +The [best reproductions](https://stackoverflow.com/help/minimal-reproducible-example) +are +[self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/) +with minimal dependencies. ```python -code goes here +# code goes here ``` diff --git a/README.md b/README.md index 0495c7b..fb05c3b 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,12 @@ API_SECRET = "425b55975eed76058ac220b7b4e8a054" username = "your_user_name" password_hash = pylast.md5("your_password") -network = pylast.LastFMNetwork(api_key=API_KEY, api_secret=API_SECRET, - username=username, password_hash=password_hash) +network = pylast.LastFMNetwork( + api_key=API_KEY, + api_secret=API_SECRET, + username=username, + password_hash=password_hash, +) # Now you can use that object everywhere artist = network.get_artist("System of a Down") diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 4c39f33..26f799c 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -11,7 +11,7 @@ from flaky import flaky import pylast -WRITE_TEST = sys.version_info[:2] == (3, 8) +WRITE_TEST = sys.version_info[:2] == (3, 9) def load_secrets(): # pragma: no cover From 2d2e73c1bc227d240a24fcdd40116a66d7944068 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Wed, 30 Dec 2020 15:56:35 +0100 Subject: [PATCH 618/768] Fixed unsafe tempfile and fixed some basic problems --- src/pylast/__init__.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 05c0361..6f0ea9d 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -23,6 +23,7 @@ import collections import hashlib import html.entities import logging +import os import shelve import ssl import tempfile @@ -421,7 +422,8 @@ class _Network: """ if not file_path: - file_path = tempfile.mktemp(prefix="pylast_tmp_") + file_descriptor, file_path = tempfile.mkstemp(prefix="pylast_tmp_") + os.close(file_descriptor) self.cache_backend = _ShelfCacheBackend(file_path) @@ -783,7 +785,7 @@ class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None): - self.shelf = shelve.open(file_path) + self.shelf = shelve.open(file_path, flag='n') self.cache_keys = set(self.shelf.keys()) def __contains__(self, key): @@ -916,7 +918,7 @@ class _Request: headers=headers, ) except Exception as e: - raise NetworkError(self.network, e) + raise NetworkError(self.network, e) from e else: conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) @@ -924,7 +926,7 @@ class _Request: try: conn.request(method="POST", url=host_subdir, body=data, headers=headers) except Exception as e: - raise NetworkError(self.network, e) + raise NetworkError(self.network, e) from e try: response = conn.getresponse() @@ -937,7 +939,7 @@ class _Request: ) response_text = _unicode(response.read()) except Exception as e: - raise MalformedResponseError(self.network, e) + raise MalformedResponseError(self.network, e) from e try: self._check_response_for_errors(response_text) @@ -961,7 +963,7 @@ class _Request: try: doc = minidom.parseString(_string(response).replace("opensearch:", "")) except Exception as e: - raise MalformedResponseError(self.network, e) + raise MalformedResponseError(self.network, e) from e e = doc.getElementsByTagName("lfm")[0] # logger.debug(doc.toprettyxml()) @@ -1042,9 +1044,6 @@ class SessionKeyGenerator: if url in self.web_auth_tokens.keys(): token = self.web_auth_tokens[url] - else: - # This will raise a WSError if token is blank or unauthorized - token = token request = _Request(self.network, "auth.getSession", {"token": token}) @@ -1397,7 +1396,12 @@ class _Taggable(_BaseObject): return seq -class WSError(Exception): +class PyLastError(Exception): + """Generic exception raised by PyLast""" + pass + + +class WSError(PyLastError): """Exception related to the Network web service""" def __init__(self, network, status, details): @@ -1441,7 +1445,7 @@ class WSError(Exception): return self.status -class MalformedResponseError(Exception): +class MalformedResponseError(PyLastError): """Exception conveying a malformed response from the music network.""" def __init__(self, network, underlying_error): @@ -1454,7 +1458,7 @@ class MalformedResponseError(Exception): ) -class NetworkError(Exception): +class NetworkError(PyLastError): """Exception conveying a problem in sending a request to Last.fm""" def __init__(self, network, underlying_error): @@ -2778,7 +2782,7 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=Fa break # success except Exception as e: if tries >= 3: - raise e + raise PyLastError() from e # Wait and try again time.sleep(1) tries += 1 @@ -2795,7 +2799,7 @@ def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=Fa main.getAttribute("totalPages") or main.getAttribute("totalpages") ) else: - raise Exception("No total pages attribute") + raise PyLastError("No total pages attribute") for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and ( @@ -2910,9 +2914,6 @@ def _number(string): def _unescape_htmlentity(string): - - # string = _unicode(string) - mapping = html.entities.name2codepoint for key in mapping: string = string.replace("&%s;" % key, chr(mapping[key])) From eca1db86222ff7657b0285bb61eb42883d11e5a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Dec 2020 14:59:17 +0000 Subject: [PATCH 619/768] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pylast/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 6f0ea9d..cd9ee21 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -785,7 +785,7 @@ class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None): - self.shelf = shelve.open(file_path, flag='n') + self.shelf = shelve.open(file_path, flag="n") self.cache_keys = set(self.shelf.keys()) def __contains__(self, key): @@ -1398,6 +1398,7 @@ class _Taggable(_BaseObject): class PyLastError(Exception): """Generic exception raised by PyLast""" + pass From e9bef6db6850399542f5a12bf2ce5312b7eb7eee Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Wed, 30 Dec 2020 17:11:38 +0100 Subject: [PATCH 620/768] Bugfix for caching between sessions --- src/pylast/__init__.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index cd9ee21..d510129 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -422,8 +422,8 @@ class _Network: """ if not file_path: - file_descriptor, file_path = tempfile.mkstemp(prefix="pylast_tmp_") - os.close(file_descriptor) + self.cache_backend = _ShelfCacheBackend.create_shelf() + return self.cache_backend = _ShelfCacheBackend(file_path) @@ -784,8 +784,11 @@ class LibreFMNetwork(_Network): class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" - def __init__(self, file_path=None): - self.shelf = shelve.open(file_path, flag="n") + def __init__(self, file_path=None, flag=None): + if flag: + self.shelf = shelve.open(file_path, flag=flag) + else: + self.shelf = shelve.open(file_path) self.cache_keys = set(self.shelf.keys()) def __contains__(self, key): @@ -801,6 +804,12 @@ class _ShelfCacheBackend: self.cache_keys.add(key) self.shelf[key] = xml_string + @classmethod + def create_shelf(cls): + file_descriptor, file_path = tempfile.mkstemp(prefix="pylast_tmp_") + os.close(file_descriptor) + return cls(file_path=file_path, flag="n") + class _Request: """Representing an abstract web service operation.""" From 36b2eeb297eef81a11894a21a4d4e85f7fc2d822 Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Wed, 30 Dec 2020 17:12:32 +0100 Subject: [PATCH 621/768] Code improvement --- src/pylast/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index d510129..fd7b3e7 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -785,7 +785,7 @@ class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" def __init__(self, file_path=None, flag=None): - if flag: + if flag is not None: self.shelf = shelve.open(file_path, flag=flag) else: self.shelf = shelve.open(file_path) From 6fe9aa632b2147eaf7278230def9ea735eb73f4e Mon Sep 17 00:00:00 2001 From: Koen van Zuijlen Date: Sat, 2 Jan 2021 00:48:32 +0100 Subject: [PATCH 622/768] Fix for user play count and user loved --- src/pylast/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 05c0361..20a087c 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -888,6 +888,9 @@ class _Request: if self.network.limit_rate: self.network._delay_call() + username = self.params.pop("username", None) + username = f"?username={username}" if username is not None else "" + data = [] for name in self.params.keys(): data.append("=".join((name, quote_plus(_string(self.params[name]))))) @@ -911,7 +914,7 @@ class _Request: try: conn.request( method="POST", - url="https://" + host_name + host_subdir, + url=f"https://{host_name}{host_subdir}{username}", body=data, headers=headers, ) @@ -922,7 +925,7 @@ class _Request: conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) try: - conn.request(method="POST", url=host_subdir, body=data, headers=headers) + conn.request(method="POST", url=f'{host_subdir}{username}', body=data, headers=headers) except Exception as e: raise NetworkError(self.network, e) @@ -1494,7 +1497,7 @@ class _Opus(_Taggable): self.artist = Artist(artist, self.network) self.title = title - self.username = username + self.username = username if username else network.username # Default to current user self.info = info def __repr__(self): From 0c546976b9c640c1c38acc57ec27dbebb29127a6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 2 Jan 2021 00:06:15 +0000 Subject: [PATCH 623/768] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pylast/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 20a087c..bec13d5 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -925,7 +925,12 @@ class _Request: conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) try: - conn.request(method="POST", url=f'{host_subdir}{username}', body=data, headers=headers) + conn.request( + method="POST", + url=f"{host_subdir}{username}", + body=data, + headers=headers, + ) except Exception as e: raise NetworkError(self.network, e) @@ -1497,7 +1502,9 @@ class _Opus(_Taggable): self.artist = Artist(artist, self.network) self.title = title - self.username = username if username else network.username # Default to current user + self.username = ( + username if username else network.username + ) # Default to current user self.info = info def __repr__(self): From 9241a0263761716658a8fedfaeef8faa8eb49e1d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Jan 2021 16:26:38 +0000 Subject: [PATCH 624/768] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 147a7e0..fe64f0d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: types: [] - repo: https://github.com/PyCQA/isort - rev: 5.6.4 + rev: 5.7.0 hooks: - id: isort @@ -31,7 +31,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.3.0 + rev: v3.4.0 hooks: - id: check-merge-conflict - id: check-yaml From f3ee6a71a79137a09a65670b372107b02907626e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 18:40:36 +0200 Subject: [PATCH 625/768] Update label config --- .github/labels.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/labels.yml b/.github/labels.yml index 13ae9e0..38b5fdb 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -97,12 +97,18 @@ - color: 0366d6 description: "For dependencies" name: dependencies +- color: 0052cc + description: "Documentation" + name: docs - color: f4660e description: "" name: Hacktoberfest - color: f4660e description: "To credit accepted Hacktoberfest PRs" name: hacktoberfest-accepted +- color: d65e88 + description: "Deploy and release" + name: release - color: fef2c0 - description: "" + description: "Unit tests, linting, CI, etc." name: test From 7a235fcc6e4db606da9ab300a66264e36fa7a092 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 18:40:46 +0200 Subject: [PATCH 626/768] Update release config --- .github/release-drafter.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index c121b37..834488e 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -23,7 +23,6 @@ exclude-labels: - "changelog: skip" template: | - ## Changes $CHANGES From 6328b9e10628f7ae22c9c69613484f51a9d5ef5f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 18:40:56 +0200 Subject: [PATCH 627/768] Name lint job after workflow --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bda0c64..f092b74 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,7 +3,7 @@ name: Lint on: [push, pull_request] jobs: - build: + lint: runs-on: ubuntu-20.04 steps: From c0fb459458727c66fe66694a9715c8d7bf78b215 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 18:41:16 +0200 Subject: [PATCH 628/768] Update test config --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12e6357..e8b978a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,7 +47,6 @@ jobs: python -m pip install -U tox - name: Tox tests - shell: bash run: | tox -e py env: From 2bf906af175ea628891f35abec0aa235ec3720f2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 18:42:55 +0200 Subject: [PATCH 629/768] Update copyright year --- src/pylast/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 05c0361..02db07c 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -3,7 +3,7 @@ # A Python interface to Last.fm and Libre.fm # # Copyright 2008-2010 Amr Hassan -# Copyright 2013-2020 hugovk +# Copyright 2013-2021 hugovk # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ from xml.dom import Node, minidom import pkg_resources __author__ = "Amr Hassan, hugovk, Mice Pápai" -__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2020 hugovk, 2017 Mice Pápai" +__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" __version__ = pkg_resources.get_distribution(__name__).version From 26ffcf5ad61765f9940e463717d0f5f3e56ecbf1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Jan 2021 20:29:53 +0200 Subject: [PATCH 630/768] Add 4.1.0 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c3a61..8a7738b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0] - 2021-01-04 +## Added + +* Add support for streaming (#336) @kvanzuijlen +* Add Python 3.9 final to Travis CI (#350) @sheetalsingala + +## Changed + +* Update copyright year (#360) @hugovk +* Replace Travis CI with GitHub Actions (#352) @hugovk +* [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci + +## Fixed + +* Set limit to 50 by default, not 1 (#355) @hugovk + + ## [4.0.0] - 2020-10-07 ## Added @@ -96,6 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) +[4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0 [3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0 [3.2.1]: https://github.com/pylast/pylast/compare/3.2.0...3.2.1 From c6e983b579c8cbe985c620976752e1ee1f4a90d3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 16:27:39 +0000 Subject: [PATCH 631/768] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b29fb44..499ca32 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.7.4 + rev: v2.9.0 hooks: - id: pyupgrade args: ["--py36-plus"] @@ -15,7 +15,7 @@ repos: types: [] - repo: https://github.com/asottile/blacken-docs - rev: v1.9.1 + rev: v1.9.2 hooks: - id: blacken-docs args: ["--target-version", "py36"] From e87da1efde3eca4a1068887d903d103696b6efdb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Feb 2021 16:28:18 +0000 Subject: [PATCH 632/768] [pre-commit.ci] pre-commit autoupdate --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 499ca32..a363863 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.9.0 + rev: v2.10.0 hooks: - id: pyupgrade args: ["--py36-plus"] @@ -33,7 +33,7 @@ repos: additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.7.0 + rev: v1.7.1 hooks: - id: python-check-blanket-noqa From 6fa502ea17fcfa7acdf722401862b4c8e4d4a1b3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 14 Mar 2021 17:56:21 +0200 Subject: [PATCH 633/768] Update release draft title --- .github/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 834488e..b853342 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,4 +1,4 @@ -name-template: "Release $RESOLVED_VERSION" +name-template: "$RESOLVED_VERSION" tag-template: "$RESOLVED_VERSION" categories: From aad860a2222e1616dc521094d5eb1a6d237db869 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 14 Mar 2021 19:37:02 +0200 Subject: [PATCH 634/768] Add 4.2.0 [CI skip] --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7738b..0b4ede3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.0] - 2021-03-14 + +## Changed + +* Fix unsafe creation of temp file for caching, and improve exception raising (#356) @kvanzuijlen +* [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci + + ## [4.1.0] - 2021-01-04 ## Added @@ -113,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Support for Python 2.7 ([#265]) +[4.2.0]: https://github.com/pylast/pylast/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0 [4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0 [3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0 From 4e645ca134d1aec6fb965fb5bc8e9189a401f6eb Mon Sep 17 00:00:00 2001 From: Chandler Swift Date: Tue, 27 Apr 2021 15:29:26 -0500 Subject: [PATCH 635/768] Set get_top_tracks limit even if it's `None` To get an unlimited number of top tracks, `_get_things` expects `params['limit']` to be set to `None`. However, this can't happen here because `None` is falsy. Fixes #366. --- src/pylast/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 1341de0..40f7468 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2531,8 +2531,7 @@ class User(_Chartable): params = self._get_params() params["period"] = period - if limit: - params["limit"] = limit + params["limit"] = limit return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) From a516a44c329d6921755ebd7cd7e5fda9e851ad95 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 30 Apr 2021 22:53:37 +0300 Subject: [PATCH 636/768] New changes are documented in GH Releases --- CHANGELOG.md | 9 ++++++--- RELEASING.md | 2 -- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4ede3..2518770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 4.2.1 and newer + +See GitHub Releases: + +- https://github.com/pylast/pylast/releases ## [4.2.0] - 2021-03-14 diff --git a/RELEASING.md b/RELEASING.md index 7e3cdfc..8181754 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,8 +10,6 @@ * [ ] Check next tag is correct, amend if needed -* [ ] Copy text into [`CHANGELOG.md`](CHANGELOG.md) - * [ ] Publish release * [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy) From ce2c1e6f769d262300c88d9c08f0df645ea6bd24 Mon Sep 17 00:00:00 2001 From: Tran Tieu Binh Date: Sat, 29 May 2021 13:57:30 +0700 Subject: [PATCH 637/768] Remove artist.shout("<3") There is no shout() method for the artist object. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index fb05c3b..c4f4ad6 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,6 @@ network = pylast.LastFMNetwork( # Now you can use that object everywhere artist = network.get_artist("System of a Down") -artist.shout("<3") track = network.get_track("Iron Maiden", "The Nomad") From 1a35601f513b7902e623da2704f0c023f09f0e3d Mon Sep 17 00:00:00 2001 From: Tran Tieu Binh Date: Sat, 29 May 2021 14:24:41 +0700 Subject: [PATCH 638/768] Remove blank line --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index c4f4ad6..77e381c 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,6 @@ network = pylast.LastFMNetwork( ) # Now you can use that object everywhere -artist = network.get_artist("System of a Down") - - track = network.get_track("Iron Maiden", "The Nomad") track.love() track.add_tags(("awesome", "favorite")) From 20cd3ff4758a51b710bcc007f1d4cff3d7e3ec35 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 Aug 2021 20:28:45 +0300 Subject: [PATCH 639/768] Update pre-commit and add quarterly autoupdate_schedule --- .pre-commit-config.yaml | 21 ++++++++++++--------- src/pylast/__init__.py | 24 ++++++++++++------------ 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a363863..3686a37 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.10.0 + rev: v2.23.1 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 21.7b0 hooks: - id: black args: ["--target-version", "py36"] @@ -15,35 +15,38 @@ repos: types: [] - repo: https://github.com/asottile/blacken-docs - rev: v1.9.2 + rev: v1.10.0 hooks: - id: blacken-docs args: ["--target-version", "py36"] additional_dependencies: [black==20.8b1] - repo: https://github.com/PyCQA/isort - rev: 5.7.0 + rev: 5.9.3 hooks: - id: isort - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + - repo: https://github.com/PyCQA/flake8 + rev: 3.9.2 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.7.1 + rev: v1.9.0 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.0.1 hooks: - id: check-merge-conflict - id: check-yaml - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 0.5.0 + rev: 0.5.1 hooks: - id: tox-ini-fmt + +ci: + autoupdate_schedule: quarterly diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 8105d52..e192fa2 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1355,7 +1355,7 @@ class _Taggable(_BaseObject): self.remove_tag(tag) def clear_tags(self): - """Clears all the user-set tags. """ + """Clears all the user-set tags.""" self.remove_tags(*(self.get_tags())) @@ -1702,7 +1702,7 @@ class Artist(_Taggable): self.info = info def __repr__(self): - return "pylast.Artist({}, {})".format(repr(self.get_name()), repr(self.network)) + return f"pylast.Artist({repr(self.get_name())}, {repr(self.network)})" def __unicode__(self): return str(self.get_name()) @@ -1886,7 +1886,7 @@ class Country(_BaseObject): self.name = name def __repr__(self): - return "pylast.Country({}, {})".format(repr(self.name), repr(self.network)) + return f"pylast.Country({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self): @@ -1902,7 +1902,7 @@ class Country(_BaseObject): return {"country": self.get_name()} def get_name(self): - """Returns the country name. """ + """Returns the country name.""" return self.name @@ -1964,7 +1964,7 @@ class Library(_BaseObject): self.user = User(user, self.network) def __repr__(self): - return "pylast.Library({}, {})".format(repr(self.user), repr(self.network)) + return f"pylast.Library({repr(self.user)}, {repr(self.network)})" @_string_output def __str__(self): @@ -2010,7 +2010,7 @@ class Tag(_Chartable): self.name = name def __repr__(self): - return "pylast.Tag({}, {})".format(repr(self.name), repr(self.network)) + return f"pylast.Tag({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self): @@ -2026,7 +2026,7 @@ class Tag(_Chartable): return {self.ws_prefix: self.get_name()} def get_name(self, properly_capitalized=False): - """Returns the name of the tag. """ + """Returns the name of the tag.""" if properly_capitalized: self.name = _extract( @@ -2149,12 +2149,12 @@ class Track(_Opus): return Album(_extract(node, "artist"), _extract(node, "title"), self.network) def love(self): - """Adds the track to the user's loved tracks. """ + """Adds the track to the user's loved tracks.""" self._request(self.ws_prefix + ".love") def unlove(self): - """Remove the track to the user's loved tracks. """ + """Remove the track to the user's loved tracks.""" self._request(self.ws_prefix + ".unlove") @@ -2220,7 +2220,7 @@ class User(_Chartable): self.name = user_name def __repr__(self): - return "pylast.User({}, {})".format(repr(self.name), repr(self.network)) + return f"pylast.User({repr(self.name)}, {repr(self.network)})" @_string_output def __str__(self): @@ -2259,7 +2259,7 @@ class User(_Chartable): return self.name def get_friends(self, limit=50, cacheable=False, stream=False): - """Returns a list of the user's friends. """ + """Returns a list of the user's friends.""" def _get_friends(): for node in _collect_nodes( @@ -2604,7 +2604,7 @@ class User(_Chartable): return self.network._get_url(domain_name, "user") % {"name": name} def get_library(self): - """Returns the associated Library object. """ + """Returns the associated Library object.""" return Library(self, self.network) From c8a64dbee9462678b2451b12def06baf1bff55b9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 Aug 2021 20:42:20 +0300 Subject: [PATCH 640/768] Test image is now gif --- tests/test_album.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_album.py b/tests/test_album.py index d6bf3e1..e3ca4f7 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -95,4 +95,4 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert self.assert_startswith(image, "https://") - self.assert_endswith(image, ".png") + self.assert_endswith(image, ".gif") From 72491f7a9920e0d0d9752a634e59548fb5588e4a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 Aug 2021 20:46:56 +0300 Subject: [PATCH 641/768] Last.fm now even skips an empty when no bio --- src/pylast/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index e192fa2..c24cd43 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1798,9 +1798,14 @@ class Artist(_Taggable): else: params = None - return self._extract_cdata_from_request( - self.ws_prefix + ".getInfo", section, params - ) + try: + bio = self._extract_cdata_from_request( + self.ws_prefix + ".getInfo", section, params + ) + except IndexError: + bio = None + + return bio def get_bio_published_date(self): """Returns the date on which the artist's biography was published.""" From ddb1b1e501f70d1e9beee853770d7ec6bcdabc86 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 Aug 2021 20:50:21 +0300 Subject: [PATCH 642/768] Fix test --- tests/test_network.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_network.py b/tests/test_network.py index b45fafa..9c3ad44 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -265,7 +265,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert isinstance(artist, pylast.Artist) - assert artist.name == "MusicBrainz Test Artist" + assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist") def test_track_mbid(self): # Arrange From a850f093f0a2df5c8904c740e2f6b7bd3e9c83c9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 Aug 2021 21:09:28 +0300 Subject: [PATCH 643/768] track.getInfo with mbid is broken at Last.fm: https://support.last.fm/t/track-getinfo-with-mbid-returns-6-track-not-found/47905 --- tests/test_network.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_network.py b/tests/test_network.py index 9c3ad44..051c0d8 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -267,6 +267,8 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(artist, pylast.Artist) assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist") + @pytest.mark.xfail(reason="Broken at Last.fm: Track not found") + # https://support.last.fm/t/track-getinfo-with-mbid-returns-6-track-not-found/47905 def test_track_mbid(self): # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" From 73e3b1b9eddf183164d1f1043bf24d69d0d23d13 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Oct 2021 16:45:32 +0000 Subject: [PATCH 644/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.23.1 → v2.29.0](https://github.com/asottile/pyupgrade/compare/v2.23.1...v2.29.0) - [github.com/psf/black: 21.7b0 → 21.9b0](https://github.com/psf/black/compare/21.7b0...21.9b0) - [github.com/asottile/blacken-docs: v1.10.0 → v1.11.0](https://github.com/asottile/blacken-docs/compare/v1.10.0...v1.11.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3686a37..a720261 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.23.1 + rev: v2.29.0 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/psf/black - rev: 21.7b0 + rev: 21.9b0 hooks: - id: black args: ["--target-version", "py36"] @@ -15,7 +15,7 @@ repos: types: [] - repo: https://github.com/asottile/blacken-docs - rev: v1.10.0 + rev: v1.11.0 hooks: - id: blacken-docs args: ["--target-version", "py36"] From 031b3ebbb1da1367613410d859ee1b3cacae5420 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 4 Oct 2021 20:36:05 +0300 Subject: [PATCH 645/768] Add support for Python 3.10 --- README.md | 3 ++- setup.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 77e381c..b297c74 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Or from requirements.txt: Note: -* pyLast 4.0+ supports Python 3.6-3.9. +* pyLast 4.3+ supports Python 3.6-3.10. +* pyLast 4.0 - 4.2 supports Python 3.6-3.9. * pyLast 3.2 - 3.3 supports Python 3.5-3.8. * pyLast 3.0 - 3.1 supports Python 3.5-3.7. * pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. diff --git a/setup.py b/setup.py index a2d891f..b49e655 100755 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ setup( "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", From 9072b98a18cd7ae3b553ddee85ebe683e7d9e7c4 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 19 Oct 2021 13:08:53 +0300 Subject: [PATCH 646/768] Rename master to main, use 3.10 final, add workflow_dispatch --- .github/workflows/deploy.yml | 15 ++++++++------- .github/workflows/labels.yml | 7 +++++-- .github/workflows/lint.yml | 2 +- .github/workflows/release-drafter.yml | 7 ++++--- .github/workflows/test.yml | 12 ++++++------ README.md | 8 ++++---- RELEASING.md | 14 +++++++------- tox.ini | 13 +++++++------ 8 files changed, 42 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1f65cb8..9b4297e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,14 +3,15 @@ name: Deploy on: push: branches: - - master + - main release: types: - published + workflow_dispatch: jobs: - build: - if: github.repository == 'pylast/pylast' + deploy: + if: github.repository_owner == 'pylast' runs-on: ubuntu-20.04 steps: @@ -29,18 +30,18 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install -U pip - python -m pip install -U setuptools twine wheel + python -m pip install -U build twine wheel - name: Build package run: | python setup.py --version - python setup.py sdist --format=gztar bdist_wheel - twine check dist/* + python -m build + twine check --strict dist/* - name: Publish package to PyPI if: github.event.action == 'published' diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index e84c13e..c22c0d0 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,12 +1,15 @@ name: Sync labels + on: push: branches: - - master + - main paths: - .github/labels.yml + workflow_dispatch: + jobs: - build: + sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f092b74..bfe362b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,6 @@ name: Lint -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] jobs: lint: diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index f1d92f9..a806a43 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,14 +4,15 @@ on: push: # branches to consider in the event; optional, defaults to all branches: - - master + - main + workflow_dispatch: jobs: update_release_draft: - if: github.repository == 'pylast/pylast' + if: github.repository_owner == 'hugovk' runs-on: ubuntu-latest steps: - # Drafts your next release notes as pull requests are merged into "master" + # Drafts your next release notes as pull requests are merged into "main" - uses: release-drafter/release-drafter@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8b978a..7bda29d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,17 +1,17 @@ name: Test -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] env: FORCE_COLOR: 1 jobs: - build: + test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10-dev", "pypy3"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"] os: [ubuntu-20.04] include: # Include new variables for Codecov @@ -35,10 +35,10 @@ jobs: with: path: ${{ steps.pip-cache.outputs.dir }} key: - ${{ matrix.os }}-${{ matrix.python-version }}-v3-${{ + ${{ matrix.os }}-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }} restore-keys: | - ${{ matrix.os }}-${{ matrix.python-version }}-v3- + ${{ matrix.os }}-${{ matrix.python-version }}-v1- - name: Install dependencies run: | @@ -56,7 +56,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 with: flags: ${{ matrix.codecov-flag }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index b297c74..3c62148 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ pyLast [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) -[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/master/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/main/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) +[![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites @@ -98,12 +98,12 @@ track.add_tags(("awesome", "favorite")) More examples in hugovk/lastfm-tools and -[tests/](https://github.com/pylast/pylast/tree/master/tests). +[tests/](https://github.com/pylast/pylast/tree/main/tests). Testing ------- -The [tests/](https://github.com/pylast/pylast/tree/master/tests) directory contains +The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with diff --git a/RELEASING.md b/RELEASING.md index 8181754..452bca6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,21 +1,21 @@ # Release Checklist -* [ ] Get master to the appropriate code release state. +- [ ] Get `main` to the appropriate code release state. [GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for - all merges to master. + all merges to `main`. [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) -* [ ] Edit release draft, adjust text if needed: +- [ ] Edit release draft, adjust text if needed: https://github.com/pylast/pylast/releases -* [ ] Check next tag is correct, amend if needed +- [ ] Check next tag is correct, amend if needed -* [ ] Publish release +- [ ] Publish release -* [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy) +- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy) has deployed to [PyPI](https://pypi.org/project/pylast/#history) -* [ ] Check installation: +- [ ] Check installation: ```bash pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)" diff --git a/tox.ini b/tox.ini index c19e202..0cdfe53 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ [tox] envlist = + lint py{py3, 310, 39, 38, 37, 36} [testenv] @@ -13,12 +14,6 @@ extras = commands = pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs} -[testenv:venv] -deps = - ipdb -commands = - {posargs} - [testenv:lint] passenv = PRE_COMMIT_COLOR @@ -27,3 +22,9 @@ deps = pre-commit commands = pre-commit run --all-files --show-diff-on-failure + +[testenv:venv] +deps = + ipdb +commands = + {posargs} From e5b9f2aa1965572b0723f1d95b8ceef6be03f60b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 19 Oct 2021 13:11:21 +0300 Subject: [PATCH 647/768] Add colour to tests --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 0cdfe53..c04ab95 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,7 @@ envlist = [testenv] passenv = + FORCE_COLOR PYLAST_API_KEY PYLAST_API_SECRET PYLAST_PASSWORD_HASH From c41f831d82c02166e887e32a63a5e19330b50ed5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 19 Oct 2021 13:17:20 +0300 Subject: [PATCH 648/768] Fix test --- tests/test_artist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 4e8d694..a911882 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -22,7 +22,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): def test_artist_is_hashable(self): # Arrange - test_artist = self.network.get_artist("Test Artist") + test_artist = self.network.get_artist("Radiohead") artist = test_artist.get_similar(limit=2)[0].item assert isinstance(artist, pylast.Artist) From 05b4ad8c629a60a13cf183deca0acadb98004ac3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 19 Oct 2021 13:57:47 +0300 Subject: [PATCH 649/768] Disable the flaky write tests --- tests/test_pylast.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 26f799c..c7cd7b3 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -3,7 +3,6 @@ Integration (not unit) tests for pylast.py """ import os -import sys import time import pytest @@ -11,7 +10,7 @@ from flaky import flaky import pylast -WRITE_TEST = sys.version_info[:2] == (3, 9) +WRITE_TEST = False def load_secrets(): # pragma: no cover From ae7d4e36259f5663f42b3f9a9ae8b8bf4af5e0df Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 10 Nov 2021 10:53:05 +0200 Subject: [PATCH 650/768] Replace deprecated pypy3 with pypy-3.8 Committed via https://github.com/asottile/all-repos --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7bda29d..130a0a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"] + python-version: ["pypy-3.8", "3.6", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-20.04] include: # Include new variables for Codecov From 3a7c83998fe6af57f13140ce08ba4aac4a690912 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 10 Nov 2021 11:44:10 +0200 Subject: [PATCH 651/768] Replace MBID for missing test album with a real one --- tests/test_network.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 051c0d8..2ed839f 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -245,7 +245,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): def test_album_mbid(self): # Arrange - mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937" + mbid = "03c91c40-49a6-44a7-90e7-a700edf97a62" # Act album = self.network.get_album_by_mbid(mbid) @@ -253,7 +253,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert isinstance(album, pylast.Album) - assert album.title.lower() == "test" + assert album.title == "Believe" assert album_mbid == mbid def test_artist_mbid(self): From b3fb55586c20cba223f4814eb3c5b30baa40aebc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 16:42:04 +0200 Subject: [PATCH 652/768] Convert setup.py to static setup.cfg and format with setup-cfg-fmt --- .pre-commit-config.yaml | 17 +++++++++----- setup.cfg | 49 +++++++++++++++++++++++++++++++++++++++++ setup.py | 39 ++------------------------------ 3 files changed, 62 insertions(+), 43 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a720261..2bf62a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.29.0 + rev: v2.29.1 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 21.11b1 hooks: - id: black args: ["--target-version", "py36"] @@ -15,19 +15,19 @@ repos: types: [] - repo: https://github.com/asottile/blacken-docs - rev: v1.11.0 + rev: v1.12.0 hooks: - id: blacken-docs args: ["--target-version", "py36"] - additional_dependencies: [black==20.8b1] + additional_dependencies: [black==21.11b1] - repo: https://github.com/PyCQA/isort - rev: 5.9.3 + rev: 5.10.1 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -43,6 +43,11 @@ repos: - id: check-merge-conflict - id: check-yaml + - repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.20.0 + hooks: + - id: setup-cfg-fmt + - repo: https://github.com/tox-dev/tox-ini-fmt rev: 0.5.1 hooks: diff --git a/setup.cfg b/setup.cfg index 191fac9..dd25e90 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,52 @@ +[metadata] +name = pylast +description = A Python interface to Last.fm and Libre.fm +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/pylast/pylast +author = Amr Hassan and Contributors +author_email = amr.hassan@gmail.com +license = Apache-2.0 +license_file = LICENSE.txt +classifiers = + Development Status :: 5 - Production/Stable + License :: OSI Approved :: Apache Software License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + Topic :: Internet + Topic :: Multimedia :: Sound/Audio + Topic :: Software Development :: Libraries :: Python Modules +keywords = + Last.fm + music + scrobble + scrobbling + +[options] +packages = find: +python_requires = >=3.6 +package_dir = =src +setup_requires = + setuptools-scm + +[options.packages.find] +where = src + +[options.extras_require] +tests = + flaky + pytest + pytest-cov + pytest-random-order + pyyaml + [flake8] max_line_length = 88 diff --git a/setup.py b/setup.py index b49e655..668794a 100755 --- a/setup.py +++ b/setup.py @@ -1,47 +1,12 @@ -from setuptools import find_packages, setup - -with open("README.md") as f: - long_description = f.read() +from setuptools import setup -def local_scheme(version): +def local_scheme(version) -> str: """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) to be able to upload to Test PyPI""" return "" setup( - name="pylast", - description="A Python interface to Last.fm and Libre.fm", - long_description=long_description, - long_description_content_type="text/markdown", - author="Amr Hassan and Contributors", - author_email="amr.hassan@gmail.com", - url="https://github.com/pylast/pylast", - license="Apache2", - keywords=["Last.fm", "music", "scrobble", "scrobbling"], - packages=find_packages(where="src"), - package_dir={"": "src"}, use_scm_version={"local_scheme": local_scheme}, - setup_requires=["setuptools_scm"], - extras_require={ - "tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] - }, - python_requires=">=3.6", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: Apache Software License", - "Topic :: Internet", - "Topic :: Multimedia :: Sound/Audio", - "Topic :: Software Development :: Libraries :: Python Modules", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - ], ) From 754d94374b1fbebb77400cdd0d763aaa587f4740 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 17:44:49 +0200 Subject: [PATCH 653/768] Use actions/setup-python's pip cache --- .editorconfig | 1 - .github/release-drafter.yml | 1 - .github/workflows/deploy.yml | 10 ++-------- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 17 ++--------------- 5 files changed, 5 insertions(+), 26 deletions(-) diff --git a/.editorconfig b/.editorconfig index b71c07e..179fd45 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,7 +11,6 @@ charset = utf-8 [*.py] indent_size = 4 indent_style = space - trim_trailing_whitespace = true # Two-space indentation diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index b853342..67eccf9 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -23,7 +23,6 @@ exclude-labels: - "changelog: skip" template: | - $CHANGES version-resolver: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9b4297e..627be8b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,18 +19,12 @@ jobs: with: fetch-depth: 0 - - name: Cache - uses: actions/cache@v2 - with: - path: ~/.cache/pip - key: deploy-${{ hashFiles('**/setup.py') }} - restore-keys: | - deploy- - - name: Set up Python uses: actions/setup-python@v2 with: python-version: "3.10" + cache: pip + cache-dependency-path: "setup.py" - name: Install dependencies run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bfe362b..769ea4e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,4 +9,4 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - uses: pre-commit/action@v2.0.0 + - uses: pre-commit/action@v2.0.3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 130a0a0..ba3aaf0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,21 +24,8 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - - - name: Get pip cache dir - id: pip-cache - run: | - echo "::set-output name=dir::$(pip cache dir)" - - - name: Cache - uses: actions/cache@v2 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.os }}-${{ matrix.python-version }}-v1-${{ - hashFiles('**/setup.py') }} - restore-keys: | - ${{ matrix.os }}-${{ matrix.python-version }}-v1- + cache: pip + cache-dependency-path: "setup.py" - name: Install dependencies run: | From 25cf4165ea938186c57f7743e682d3b8e6d62501 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 17:44:55 +0200 Subject: [PATCH 654/768] Fix typo --- .github/workflows/release-drafter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index a806a43..cb11924 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -9,7 +9,7 @@ on: jobs: update_release_draft: - if: github.repository_owner == 'hugovk' + if: github.repository_owner == 'pylast' runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" From b48fbb4eb8c2d89b1c19291faca9b460a5cfa09d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 18:07:40 +0200 Subject: [PATCH 655/768] Speedup: Use faster importlib.metadata for getting version --- setup.cfg | 3 +++ src/pylast/__init__.py | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index dd25e90..7b806d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,6 +6,7 @@ long_description_content_type = text/markdown url = https://github.com/pylast/pylast author = Amr Hassan and Contributors author_email = amr.hassan@gmail.com +maintainer = Hugo van Kemenade license = Apache-2.0 license_file = LICENSE.txt classifiers = @@ -31,6 +32,8 @@ keywords = [options] packages = find: +install_requires = + importlib-metadata;python_version < '3.8' python_requires = >=3.6 package_dir = =src setup_requires = diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c24cd43..43cecce 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -33,13 +33,18 @@ from http.client import HTTPSConnection from urllib.parse import quote_plus from xml.dom import Node, minidom -import pkg_resources +try: + # Python 3.8+ + import importlib.metadata as importlib_metadata +except ImportError: + # Python 3.7 and lower + import importlib_metadata __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" -__version__ = pkg_resources.get_distribution(__name__).version +__version__ = importlib_metadata.version(__name__) # 1 : This error does not exist From 8b66e6900480611386425d9b01d2d279fafcc36d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 18:43:32 +0200 Subject: [PATCH 656/768] Drop support for soon-EOL Python 3.6 --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 9 +++------ README.md | 21 ++++++++------------- setup.cfg | 3 +-- tox.ini | 2 +- 5 files changed, 14 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba3aaf0..f133aeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-20.04] include: # Include new variables for Codecov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bf62a8..f9b4cb2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,22 +3,19 @@ repos: rev: v2.29.1 hooks: - id: pyupgrade - args: ["--py36-plus"] + args: [--py37-plus] - repo: https://github.com/psf/black rev: 21.11b1 hooks: - id: black - args: ["--target-version", "py36"] - # override until resolved: https://github.com/psf/black/issues/402 - files: \.pyi?$ - types: [] + args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs rev: v1.12.0 hooks: - id: blacken-docs - args: ["--target-version", "py36"] + args: [--target-version=py37] additional_dependencies: [black==21.11b1] - repo: https://github.com/PyCQA/isort diff --git a/README.md b/README.md index 3c62148..1232eb1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -pyLast -====== +# pyLast [![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/) @@ -14,8 +13,7 @@ such as [Libre.fm](https://libre.fm/). Use the pydoc utility for help on usage or see [tests/](tests/) for examples. -Installation ------------- +## Installation Install via pip: @@ -32,11 +30,12 @@ python3 -m pip install -U git+https://github.com/pylast/pylast Or from requirements.txt: ```txt --e git://github.com/pylast/pylast.git#egg=pylast +-e https://github.com/pylast/pylast.git#egg=pylast ``` Note: +* pyLast 5.0+ supports Python 3.7-3.10. * pyLast 4.3+ supports Python 3.6-3.10. * pyLast 4.0 - 4.2 supports Python 3.6-3.9. * pyLast 3.2 - 3.3 supports Python 3.5-3.8. @@ -48,8 +47,7 @@ Note: * pyLast 0.5 supports Python 2, 3. * pyLast < 0.5 supports Python 2. -Features --------- +## Features * Simple public interface. * Access to all the data exposed by the Last.fm web services. @@ -60,8 +58,7 @@ Features * Support for other API-compatible networks like Libre.fm. -Getting started ---------------- +## Getting started Here's some simple code example to get you started. In order to create any object from pyLast, you need a `Network` object which represents a social music network that is @@ -100,8 +97,7 @@ More examples in hugovk/lastfm-tools and [tests/](https://github.com/pylast/pylast/tree/main/tests). -Testing -------- +## Testing The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains integration and unit tests with Last.fm, and plenty of code examples. @@ -140,8 +136,7 @@ coverage html # for HTML report open htmlcov/index.html ``` -Logging -------- +## Logging To enable from your own code: diff --git a/setup.cfg b/setup.cfg index 7b806d9..5a80546 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,6 @@ classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -34,7 +33,7 @@ keywords = packages = find: install_requires = importlib-metadata;python_version < '3.8' -python_requires = >=3.6 +python_requires = >=3.7 package_dir = =src setup_requires = setuptools-scm diff --git a/tox.ini b/tox.ini index c04ab95..9022a23 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = lint - py{py3, 310, 39, 38, 37, 36} + py{py3, 310, 39, 38, 37} [testenv] passenv = From 129e4392fcc3ef4b6f5538f209717eec828b7f16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jan 2022 16:58:33 +0000 Subject: [PATCH 657/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.29.1 → v2.31.0](https://github.com/asottile/pyupgrade/compare/v2.29.1...v2.31.0) - [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0) - [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0) - [github.com/tox-dev/tox-ini-fmt: 0.5.1 → 0.5.2](https://github.com/tox-dev/tox-ini-fmt/compare/0.5.1...0.5.2) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bf62a8..4706c19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.29.1 + rev: v2.31.0 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/psf/black - rev: 21.11b1 + rev: 21.12b0 hooks: - id: black args: ["--target-version", "py36"] @@ -38,7 +38,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-merge-conflict - id: check-yaml @@ -49,7 +49,7 @@ repos: - id: setup-cfg-fmt - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 0.5.1 + rev: 0.5.2 hooks: - id: tox-ini-fmt From 9676714dcf6370eb19cf323234014fd5ddec3bc0 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 12 Jan 2022 13:04:27 +0200 Subject: [PATCH 658/768] Strip invalid XML characters from response --- src/pylast/__init__.py | 24 ++++++++++++++++++++---- tests/unicode_test.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 54d4d40..e32e849 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -24,6 +24,7 @@ import hashlib import html.entities import logging import os +import re import shelve import ssl import tempfile @@ -969,7 +970,7 @@ class _Request: conn.close() return response_text - def execute(self, cacheable=False): + def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document: """Returns the XML DOM response of the POST Request from the server""" if self.network.is_caching_enabled() and cacheable: @@ -977,13 +978,12 @@ class _Request: else: response = self._download_response() - return minidom.parseString(_string(response).replace("opensearch:", "")) + return _parse_response(response) def _check_response_for_errors(self, response): """Checks the response for errors and raises one if any exists.""" - try: - doc = minidom.parseString(_string(response).replace("opensearch:", "")) + doc = _parse_response(response) except Exception as e: raise MalformedResponseError(self.network, e) from e @@ -2950,4 +2950,20 @@ def _unescape_htmlentity(string): return string +def _parse_response(response: str) -> xml.dom.minidom.Document: + response = _string(response).replace("opensearch:", "") + try: + doc = minidom.parseString(response) + except xml.parsers.expat.ExpatError: + # Try again. For performance, we only remove when needed in rare cases. + doc = minidom.parseString(_remove_invalid_xml_chars(response)) + return doc + + +def _remove_invalid_xml_chars(string: str) -> str: + return re.sub( + r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string + ) + + # End of file diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 7b3c271..350256c 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -27,3 +27,42 @@ def test_get_cache_key(artist): def test_cast_and_hash(obj): assert type(str(obj)) is str assert isinstance(hash(obj), int) + + +@pytest.mark.parametrize( + "test_input, expected", + [ + ( + # Plain text + 'test album name', + 'test album name', + ), + ( + # Contains Unicode ENQ Enquiry control character + 'test album \u0005name', + 'test album name', + ), + ], +) +def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None: + assert pylast._remove_invalid_xml_chars(test_input) == expected + + +@pytest.mark.parametrize( + "test_input, expected", + [ + ( + # Plain text + 'test album name', + 'test album name', + ), + ( + # Contains Unicode ENQ Enquiry control character + 'test album \u0005name', + 'test album name', + ), + ], +) +def test__parse_response(test_input: str, expected: str) -> None: + doc = pylast._parse_response(test_input) + assert doc.toxml() == expected From c63e0a75ef4e3a5ed450f2957722a8e09a615b92 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 18:02:15 +0200 Subject: [PATCH 659/768] Restore support for Python 3.6 --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 6 +++--- setup.cfg | 3 ++- tox.ini | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f133aeb..ba3aaf0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10"] + python-version: ["pypy-3.8", "3.6", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-20.04] include: # Include new variables for Codecov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f14266..c1ee091 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,19 +3,19 @@ repos: rev: v2.31.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py36-plus] - repo: https://github.com/psf/black rev: 21.12b0 hooks: - id: black - args: [--target-version=py37] + args: [--target-version=py36] - repo: https://github.com/asottile/blacken-docs rev: v1.12.0 hooks: - id: blacken-docs - args: [--target-version=py37] + args: [--target-version=py36] additional_dependencies: [black==21.11b1] - repo: https://github.com/PyCQA/isort diff --git a/setup.cfg b/setup.cfg index 5a80546..7b806d9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,6 +14,7 @@ classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -33,7 +34,7 @@ keywords = packages = find: install_requires = importlib-metadata;python_version < '3.8' -python_requires = >=3.7 +python_requires = >=3.6 package_dir = =src setup_requires = setuptools-scm diff --git a/tox.ini b/tox.ini index 9022a23..c04ab95 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = lint - py{py3, 310, 39, 38, 37} + py{py3, 310, 39, 38, 37, 36} [testenv] passenv = From 3b7cb9c8c7d7f79f9a4b426416169f4a81b213c1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 19:04:38 +0200 Subject: [PATCH 660/768] Deprecate is_streamable and is_fulltrack_available --- src/pylast/__init__.py | 50 +++++++++++++++++++++++++++++------------- tests/test_artist.py | 3 ++- tests/test_track.py | 6 +++-- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index e32e849..d22bc3a 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -29,6 +29,7 @@ import shelve import ssl import tempfile import time +import warnings import xml.dom from http.client import HTTPSConnection from urllib.parse import quote_plus @@ -1783,13 +1784,18 @@ class Artist(_Taggable): return self.listener_count def is_streamable(self): - """Returns True if the artist is streamable.""" - - return bool( - _number( - _extract(self._request(self.ws_prefix + ".getInfo", True), "streamable") - ) + """Returns True if the artist is streamable: always False because Last.fm has + deprecated the Radio API.""" + warnings.warn( + "Always returns False. Last.fm has deprecated the Radio API and will " + "it at some point. is_streamable() will be removed in pylast 5.0.0. " + "See https://www.last.fm/api/radio and " + "https://support.last.fm/t/" + "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", + DeprecationWarning, + stacklevel=2, ) + return False def get_bio(self, section, language=None): """ @@ -2130,18 +2136,32 @@ class Track(_Opus): return bool(loved) def is_streamable(self): - """Returns True if the track is available at Last.fm.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - return _extract(doc, "streamable") == "1" + """Returns True if the artist is streamable: always False because Last.fm has + deprecated the Radio API.""" + warnings.warn( + "Always returns False. Last.fm has deprecated the Radio API and will " + "it at some point. is_streamable() will be removed in pylast 5.0.0. " + "See https://www.last.fm/api/radio and " + "https://support.last.fm/t/" + "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", + DeprecationWarning, + stacklevel=2, + ) + return False def is_fulltrack_available(self): - """Returns True if the full track is available for streaming.""" - - doc = self._request(self.ws_prefix + ".getInfo", True) - return ( - doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1" + """Returns True if the full track is available for streaming: always False + because Last.fm has deprecated the Radio API.""" + warnings.warn( + "Always returns False. Last.fm has deprecated the Radio API and will " + "remove it at some point. is_fulltrack_available() will be removed in " + "pylast 5.0.0. See https://www.last.fm/api/radio and " + "https://support.last.fm/t/" + "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", + DeprecationWarning, + stacklevel=2, ) + return False def get_album(self): """Returns the album object of this track.""" diff --git a/tests/test_artist.py b/tests/test_artist.py index a911882..463af69 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -229,7 +229,8 @@ class TestPyLastArtist(TestPyLastWithLastFm): mbid = artist1.get_mbid() playcount = artist1.get_playcount() - streamable = artist1.is_streamable() + with pytest.warns(DeprecationWarning): + streamable = artist1.is_streamable() name = artist1.get_name(properly_capitalized=False) name_cap = artist1.get_name(properly_capitalized=True) diff --git a/tests/test_track.py b/tests/test_track.py index b56c018..e7ea5e3 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -123,7 +123,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): track = pylast.Track("Nirvana", "Lithium", self.network) # Act - streamable = track.is_streamable() + with pytest.warns(DeprecationWarning): + streamable = track.is_streamable() # Assert assert not streamable @@ -133,7 +134,8 @@ class TestPyLastTrack(TestPyLastWithLastFm): track = pylast.Track("Nirvana", "Lithium", self.network) # Act - fulltrack_available = track.is_fulltrack_available() + with pytest.warns(DeprecationWarning): + fulltrack_available = track.is_fulltrack_available() # Assert assert not fulltrack_available From d672e89f233424eb564f127c260aafaee7714ce7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 21:15:11 +0200 Subject: [PATCH 661/768] Is an xfail passing unexpectedly? Make it fail --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pytest.ini b/pytest.ini index 34667c8..3f83bd3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,3 +2,5 @@ filterwarnings = once::DeprecationWarning once::PendingDeprecationWarning + +xfail_strict=true From 1841fb66dc9c2b15a803c0c56f4bb9c50907aeb3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 22:07:03 +0200 Subject: [PATCH 662/768] This test now passes, although some other MBID searches are still broken --- tests/test_network.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_network.py b/tests/test_network.py index 2ed839f..2f743ab 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -267,8 +267,6 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(artist, pylast.Artist) assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist") - @pytest.mark.xfail(reason="Broken at Last.fm: Track not found") - # https://support.last.fm/t/track-getinfo-with-mbid-returns-6-track-not-found/47905 def test_track_mbid(self): # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" From 3ffe7cf65ac4336322958367d284f158c0c6c104 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 22:26:16 +0200 Subject: [PATCH 663/768] test_get_userplaycount now passes --- tests/test_artist.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_artist.py b/tests/test_artist.py index 463af69..8cc4f85 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -268,7 +268,6 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert corrected_artist_name == "Guns N' Roses" - @pytest.mark.xfail def test_get_userplaycount(self): # Arrange artist = pylast.Artist("John Lennon", self.network, username=self.username) @@ -277,4 +276,4 @@ class TestPyLastArtist(TestPyLastWithLastFm): playcount = artist.get_userplaycount() # Assert - assert playcount >= 0 # whilst xfail: # pragma: no cover + assert playcount >= 0 From b151dd0c9322783470a4a1772a0cef69c74fc733 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 22:38:33 +0200 Subject: [PATCH 664/768] Remove Mergify, use native GitHub auto-merge instead --- .github/labels.yml | 3 --- .mergify.yml | 8 -------- 2 files changed, 11 deletions(-) delete mode 100644 .mergify.yml diff --git a/.github/labels.yml b/.github/labels.yml index 38b5fdb..090914a 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -91,9 +91,6 @@ - color: b60205 description: Removal of a feature, usually done in major releases name: removal -- color: 2d18b2 - description: "To automatically merge PRs that are ready" - name: automerge - color: 0366d6 description: "For dependencies" name: dependencies diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index dad8639..0000000 --- a/.mergify.yml +++ /dev/null @@ -1,8 +0,0 @@ -pull_request_rules: - - name: Automatic merge on approval - conditions: - - label=automerge - - status-success=build - actions: - merge: - method: merge From 6465f4cf51f61bab12fd470c9fe95fec3e2b23e9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 31 Jan 2022 12:50:28 +0200 Subject: [PATCH 665/768] Update link to deploy action --- RELEASING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASING.md b/RELEASING.md index 452bca6..9a4d1bc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,7 +12,7 @@ - [ ] Publish release -- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy) +- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml) has deployed to [PyPI](https://pypi.org/project/pylast/#history) - [ ] Check installation: From d610721167f372bb426dc9b921712fdd6768509a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 25 Jan 2022 00:42:03 +0200 Subject: [PATCH 666/768] Drop support for Python EOL 3.6 --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 6 +++--- setup.cfg | 3 +-- tox.ini | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba3aaf0..f133aeb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-20.04] include: # Include new variables for Codecov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1ee091..4f14266 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,19 +3,19 @@ repos: rev: v2.31.0 hooks: - id: pyupgrade - args: [--py36-plus] + args: [--py37-plus] - repo: https://github.com/psf/black rev: 21.12b0 hooks: - id: black - args: [--target-version=py36] + args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs rev: v1.12.0 hooks: - id: blacken-docs - args: [--target-version=py36] + args: [--target-version=py37] additional_dependencies: [black==21.11b1] - repo: https://github.com/PyCQA/isort diff --git a/setup.cfg b/setup.cfg index 7b806d9..5a80546 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,7 +14,6 @@ classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -34,7 +33,7 @@ keywords = packages = find: install_requires = importlib-metadata;python_version < '3.8' -python_requires = >=3.6 +python_requires = >=3.7 package_dir = =src setup_requires = setuptools-scm diff --git a/tox.ini b/tox.ini index c04ab95..9022a23 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = lint - py{py3, 310, 39, 38, 37, 36} + py{py3, 310, 39, 38, 37} [testenv] passenv = From bb05699252b55f1cc6ccda34487b40920b51645e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 31 Jan 2022 12:56:38 +0200 Subject: [PATCH 667/768] Remove deprecated is_streamable and is_fulltrack_available --- src/pylast/__init__.py | 43 ------------------------------------------ tests/test_artist.py | 3 --- tests/test_track.py | 22 --------------------- 3 files changed, 68 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index d22bc3a..dfc0b1c 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -29,7 +29,6 @@ import shelve import ssl import tempfile import time -import warnings import xml.dom from http.client import HTTPSConnection from urllib.parse import quote_plus @@ -1783,20 +1782,6 @@ class Artist(_Taggable): ) return self.listener_count - def is_streamable(self): - """Returns True if the artist is streamable: always False because Last.fm has - deprecated the Radio API.""" - warnings.warn( - "Always returns False. Last.fm has deprecated the Radio API and will " - "it at some point. is_streamable() will be removed in pylast 5.0.0. " - "See https://www.last.fm/api/radio and " - "https://support.last.fm/t/" - "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", - DeprecationWarning, - stacklevel=2, - ) - return False - def get_bio(self, section, language=None): """ Returns a section of the bio. @@ -2135,34 +2120,6 @@ class Track(_Opus): loved = _number(_extract(doc, "userloved")) return bool(loved) - def is_streamable(self): - """Returns True if the artist is streamable: always False because Last.fm has - deprecated the Radio API.""" - warnings.warn( - "Always returns False. Last.fm has deprecated the Radio API and will " - "it at some point. is_streamable() will be removed in pylast 5.0.0. " - "See https://www.last.fm/api/radio and " - "https://support.last.fm/t/" - "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", - DeprecationWarning, - stacklevel=2, - ) - return False - - def is_fulltrack_available(self): - """Returns True if the full track is available for streaming: always False - because Last.fm has deprecated the Radio API.""" - warnings.warn( - "Always returns False. Last.fm has deprecated the Radio API and will " - "remove it at some point. is_fulltrack_available() will be removed in " - "pylast 5.0.0. See https://www.last.fm/api/radio and " - "https://support.last.fm/t/" - "is-the-streamable-attribute-broken-it-always-returns-0/39723/3", - DeprecationWarning, - stacklevel=2, - ) - return False - def get_album(self): """Returns the album object of this track.""" if "album" in self.info and self.info["album"] is not None: diff --git a/tests/test_artist.py b/tests/test_artist.py index 8cc4f85..44bacfd 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -229,8 +229,6 @@ class TestPyLastArtist(TestPyLastWithLastFm): mbid = artist1.get_mbid() playcount = artist1.get_playcount() - with pytest.warns(DeprecationWarning): - streamable = artist1.is_streamable() name = artist1.get_name(properly_capitalized=False) name_cap = artist1.get_name(properly_capitalized=True) @@ -240,7 +238,6 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert name.lower() == name_cap.lower() assert url == "https://www.last.fm/music/radiohead" assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711" - assert isinstance(streamable, bool) def test_artist_eq_none_is_false(self): # Arrange diff --git a/tests/test_track.py b/tests/test_track.py index e7ea5e3..00f4eca 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -118,28 +118,6 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert duration >= 200000 - def test_track_is_streamable(self): - # Arrange - track = pylast.Track("Nirvana", "Lithium", self.network) - - # Act - with pytest.warns(DeprecationWarning): - streamable = track.is_streamable() - - # Assert - assert not streamable - - def test_track_is_fulltrack_available(self): - # Arrange - track = pylast.Track("Nirvana", "Lithium", self.network) - - # Act - with pytest.warns(DeprecationWarning): - fulltrack_available = track.is_fulltrack_available() - - # Assert - assert not fulltrack_available - def test_track_get_album(self): # Arrange track = pylast.Track("Nirvana", "Lithium", self.network) From 44ade40579610340bd0eac51e81f0a0be9055e98 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 21 Nov 2021 23:47:01 +0200 Subject: [PATCH 668/768] Replace http.client with HTTPX --- setup.cfg | 1 + src/pylast/__init__.py | 84 ++++++++++++++++-------------------------- tests/test_network.py | 5 +-- 3 files changed, 35 insertions(+), 55 deletions(-) diff --git a/setup.cfg b/setup.cfg index 5a80546..a48f245 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ keywords = [options] packages = find: install_requires = + httpx importlib-metadata;python_version < '3.8' python_requires = >=3.7 package_dir = =src diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index dfc0b1c..910f847 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -30,10 +30,11 @@ import ssl import tempfile import time import xml.dom -from http.client import HTTPSConnection from urllib.parse import quote_plus from xml.dom import Node, minidom +import httpx + try: # Python 3.8+ import importlib.metadata as importlib_metadata @@ -125,6 +126,12 @@ DELAY_TIME = 0.2 # Python >3.4 has sane defaults SSL_CONTEXT = ssl.create_default_context() +HEADERS = { + "Content-type": "application/x-www-form-urlencoded", + "Accept-Charset": "utf-8", + "User-Agent": f"pylast/{__version__}", +} + logger = logging.getLogger(__name__) logging.getLogger(__name__).addHandler(logging.NullHandler()) @@ -390,7 +397,7 @@ class _Network: def enable_proxy(self, host, port): """Enable a default web proxy""" - self.proxy = [host, _number(port)] + self.proxy = f"{host}:{_number(port)}" self.proxy_enabled = True def disable_proxy(self): @@ -906,68 +913,41 @@ class _Request: self.network._delay_call() username = self.params.pop("username", None) - username = f"?username={username}" if username is not None else "" - - data = [] - for name in self.params.keys(): - data.append("=".join((name, quote_plus(_string(self.params[name]))))) - data = "&".join(data) - - headers = { - "Content-type": "application/x-www-form-urlencoded", - "Accept-Charset": "utf-8", - "User-Agent": "pylast/" + __version__, - } + username = "" if username is None else f"?username={username}" (host_name, host_subdir) = self.network.ws_server if self.network.is_proxy_enabled(): - conn = HTTPSConnection( - context=SSL_CONTEXT, - host=self.network._get_proxy()[0], - port=self.network._get_proxy()[1], + client = httpx.Client( + verify=SSL_CONTEXT, + base_url=f"https://{host_name}", + headers=HEADERS, + proxies=f"http://{self.network._get_proxy()}", + ) + else: + client = httpx.Client( + verify=SSL_CONTEXT, + base_url=f"https://{host_name}", + headers=HEADERS, ) - try: - conn.request( - method="POST", - url=f"https://{host_name}{host_subdir}{username}", - body=data, - headers=headers, - ) - except Exception as e: - raise NetworkError(self.network, e) from e - - else: - conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name) - - try: - conn.request( - method="POST", - url=f"{host_subdir}{username}", - body=data, - headers=headers, - ) - except Exception as e: - raise NetworkError(self.network, e) from e - try: - response = conn.getresponse() - if response.status in [500, 502, 503, 504]: - raise WSError( - self.network, - response.status, - "Connection to the API failed with HTTP code " - + str(response.status), - ) - response_text = _unicode(response.read()) + response = client.post(f"{host_subdir}{username}", data=self.params) except Exception as e: - raise MalformedResponseError(self.network, e) from e + raise NetworkError(self.network, e) from e + + if response.status_code in (500, 502, 503, 504): + raise WSError( + self.network, + response.status_code, + f"Connection to the API failed with HTTP code {response.status_code}", + ) + response_text = _unicode(response.read()) try: self._check_response_for_errors(response_text) finally: - conn.close() + client.close() return response_text def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document: diff --git a/tests/test_network.py b/tests/test_network.py index 2f743ab..ad141f2 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ @@ -297,13 +296,13 @@ class TestPyLastNetwork(TestPyLastWithLastFm): def test_proxy(self): # Arrange - host = "https://example.com" + host = "example.com" port = 1234 # Act / Assert self.network.enable_proxy(host, port) assert self.network.is_proxy_enabled() - assert self.network._get_proxy() == ["https://example.com", 1234] + assert self.network._get_proxy() == "example.com:1234" self.network.disable_proxy() assert not self.network.is_proxy_enabled() From 122c870312666a13809d3ad94db2501b0c342d77 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 11 Jan 2022 13:41:26 +0200 Subject: [PATCH 669/768] Replace _string with str --- src/pylast/__init__.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 910f847..3f00b5a 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1101,7 +1101,7 @@ Image = collections.namedtuple( def _string_output(func): def r(*args): - return _string(func(*args)) + return str(func(*args)) return r @@ -2730,12 +2730,6 @@ def _unicode(text): return str(text) -def _string(string): - if isinstance(string, str): - return string - return str(string) - - def cleanup_nodes(doc): """ Remove text nodes containing only whitespace @@ -2881,7 +2875,7 @@ def _extract_tracks(doc, network): def _url_safe(text): """Does all kinds of tricks on a text to make it safe to use in a URL.""" - return quote_plus(quote_plus(_string(text))).lower() + return quote_plus(quote_plus(str(text))).lower() def _number(string): @@ -2908,7 +2902,7 @@ def _unescape_htmlentity(string): def _parse_response(response: str) -> xml.dom.minidom.Document: - response = _string(response).replace("opensearch:", "") + response = str(response).replace("opensearch:", "") try: doc = minidom.parseString(response) except xml.parsers.expat.ExpatError: From a418f64b15600ebfe2cd6234de833f97fd5ce1a4 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 11 Jan 2022 13:44:19 +0200 Subject: [PATCH 670/768] Simplify _unicode --- src/pylast/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 3f00b5a..c1e3fa0 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -2724,8 +2724,6 @@ def md5(text): def _unicode(text): if isinstance(text, bytes): return str(text, "utf-8") - elif isinstance(text, str): - return text else: return str(text) From da2e7152ba36b49bb9cadbae173194749a07f50c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 15 Jan 2022 18:46:23 +0200 Subject: [PATCH 671/768] Update blacken-docs to match main black --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f14266..23bc55c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py37] - additional_dependencies: [black==21.11b1] + additional_dependencies: [black==21.12b0] - repo: https://github.com/PyCQA/isort rev: 5.10.1 From 1a45c3b91914fc4477156d9dfc4a32e68f573179 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 17:23:08 +0200 Subject: [PATCH 672/768] Allow setting multiple proxies + some cleanup --- src/pylast/__init__.py | 33 ++++++++++++++------------------- tests/test_network.py | 7 +++---- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c1e3fa0..1360889 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -19,6 +19,8 @@ # # https://github.com/pylast/pylast +from __future__ import annotations + import collections import hashlib import html.entities @@ -194,7 +196,6 @@ class _Network: self.urls = urls self.cache_backend = None - self.proxy_enabled = False self.proxy = None self.last_call_time = 0 self.limit_rate = False @@ -394,26 +395,20 @@ class _Network: return seq - def enable_proxy(self, host, port): - """Enable a default web proxy""" + def enable_proxy(self, proxy: str | dict) -> None: + """Enable default web proxy. + Multiple proxies can be passed as a `dict`, see + https://www.python-httpx.org/advanced/#http-proxying + """ + self.proxy = proxy - self.proxy = f"{host}:{_number(port)}" - self.proxy_enabled = True - - def disable_proxy(self): + def disable_proxy(self) -> None: """Disable using the web proxy""" + self.proxy = None - self.proxy_enabled = False - - def is_proxy_enabled(self): - """Returns True if a web proxy is enabled.""" - - return self.proxy_enabled - - def _get_proxy(self): - """Returns proxy details.""" - - return self.proxy + def is_proxy_enabled(self) -> bool: + """Returns True if web proxy is enabled.""" + return self.proxy is not None def enable_rate_limit(self): """Enables rate limiting for this network""" @@ -922,7 +917,7 @@ class _Request: verify=SSL_CONTEXT, base_url=f"https://{host_name}", headers=HEADERS, - proxies=f"http://{self.network._get_proxy()}", + proxies=self.network.proxy, ) else: client = httpx.Client( diff --git a/tests/test_network.py b/tests/test_network.py index ad141f2..8937c53 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -296,13 +296,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm): def test_proxy(self): # Arrange - host = "example.com" - port = 1234 + proxy = "http://example.com:1234" # Act / Assert - self.network.enable_proxy(host, port) + self.network.enable_proxy(proxy) assert self.network.is_proxy_enabled() - assert self.network._get_proxy() == "example.com:1234" + assert self.network.proxy == "http://example.com:1234" self.network.disable_proxy() assert not self.network.is_proxy_enabled() From f7090f26a0dfcbe7a46ecbf285480a3a6a284d51 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Feb 2022 16:38:08 +0200 Subject: [PATCH 673/768] Output coverage XML for Codecov to upload --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 9022a23..48e8339 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ passenv = extras = tests commands = - pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs} + pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --cov-report xml --random-order {posargs} [testenv:lint] passenv = From fe7484b3cac5b6d9c26742388548d054683a1aa5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Feb 2022 16:30:09 +0200 Subject: [PATCH 674/768] If album has no MBID, album.get_getmbid() returns None --- src/pylast/__init__.py | 5 ++--- tests/test_album.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 1360889..cfbe52f 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -18,7 +18,6 @@ # limitations under the License. # # https://github.com/pylast/pylast - from __future__ import annotations import collections @@ -1595,7 +1594,7 @@ class _Opus(_Taggable): ) ) - def get_mbid(self): + def get_mbid(self) -> str | None: """Returns the MusicBrainz ID of the album or track.""" doc = self._request(self.ws_prefix + ".getInfo", cacheable=True) @@ -1604,7 +1603,7 @@ class _Opus(_Taggable): lfm = doc.getElementsByTagName("lfm")[0] opus = next(self._get_children_by_tag_name(lfm, self.ws_prefix)) mbid = next(self._get_children_by_tag_name(opus, "mbid")) - return mbid.firstChild.nodeValue + return mbid.firstChild.nodeValue if mbid.firstChild else None except StopIteration: return None diff --git a/tests/test_album.py b/tests/test_album.py index e3ca4f7..56c469b 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -96,3 +96,23 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert self.assert_startswith(image, "https://") self.assert_endswith(image, ".gif") + + def test_mbid(self): + # Arrange + album = self.network.get_album("Radiohead", "OK Computer") + + # Act + mbid = album.get_mbid() + + # Assert + assert mbid == "0b6b4ba0-d36f-47bd-b4ea-6a5b91842d29" + + def test_no_mbid(self): + # Arrange + album = self.network.get_album("Test Artist", "Test Album") + + # Act + mbid = album.get_mbid() + + # Assert + assert mbid is None From b726227d5d5603e1182705e08ff0e8de29cacf7d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 1 Mar 2022 11:48:22 +0200 Subject: [PATCH 675/768] Upgrade to actions/setup-python@v3 Committed via https://github.com/asottile/all-repos --- .github/workflows/deploy.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 627be8b..5bd3973 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" cache: pip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 769ea4e..d9014a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,5 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 - uses: pre-commit/action@v2.0.3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f133aeb..c9dc716 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} cache: pip From dd8836e59b8806d5a78818a9bcad0844d4d0c84c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 3 Mar 2022 13:15:26 +0200 Subject: [PATCH 676/768] Logging: log method names at INFO level, also log API return data at DEBUG level --- README.md | 8 ++++++-- src/pylast/__init__.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1232eb1..1a2a667 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ To enable from your own code: import logging import pylast -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) + network = pylast.LastFMNetwork(...) ``` @@ -152,5 +153,8 @@ network = pylast.LastFMNetwork(...) To enable from pytest: ```sh -pytest --log-cli-level debug -k test_album_search_images +pytest --log-cli-level info -k test_album_search_images ``` + +To also see data returned from the API, use `level=logging.DEBUG` or +`--log-cli-level debug` instead. diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index cfbe52f..3065e7d 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -822,7 +822,7 @@ class _Request: """Representing an abstract web service operation.""" def __init__(self, network, method_name, params=None): - logger.debug(method_name) + logger.info(method_name) if params is None: params = {} @@ -962,7 +962,7 @@ class _Request: raise MalformedResponseError(self.network, e) from e e = doc.getElementsByTagName("lfm")[0] - # logger.debug(doc.toprettyxml()) + logger.debug(doc.toprettyxml()) if e.getAttribute("status") != "ok": e = doc.getElementsByTagName("error")[0] From 5f8d150652dbbf4f3dd4636bc5655824d16430fa Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 17:34:20 +0200 Subject: [PATCH 677/768] Remove redundant _get_cache_backend and add some typing --- src/pylast/__init__.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 3065e7d..2077e0e 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -409,44 +409,37 @@ class _Network: """Returns True if web proxy is enabled.""" return self.proxy is not None - def enable_rate_limit(self): + def enable_rate_limit(self) -> None: """Enables rate limiting for this network""" self.limit_rate = True - def disable_rate_limit(self): + def disable_rate_limit(self) -> None: """Disables rate limiting for this network""" self.limit_rate = False - def is_rate_limited(self): + def is_rate_limited(self) -> bool: """Return True if web service calls are rate limited""" return self.limit_rate - def enable_caching(self, file_path=None): + def enable_caching(self, file_path=None) -> None: """Enables caching request-wide for all cacheable calls. * file_path: A file path for the backend storage file. If None set, a temp file would probably be created, according the backend. """ - if not file_path: self.cache_backend = _ShelfCacheBackend.create_shelf() return self.cache_backend = _ShelfCacheBackend(file_path) - def disable_caching(self): + def disable_caching(self) -> None: """Disables all caching features.""" - self.cache_backend = None - def is_caching_enabled(self): + def is_caching_enabled(self) -> bool: """Returns True if caching is enabled.""" - - return not (self.cache_backend is None) - - def _get_cache_backend(self): - - return self.cache_backend + return self.cache_backend is not None def search_for_album(self, album_name): """Searches for an album by its name. Returns a AlbumSearch object. @@ -839,7 +832,7 @@ class _Request: self.params["method"] = method_name if network.is_caching_enabled(): - self.cache = network._get_cache_backend() + self.cache = network.cache_backend if self.session_key: self.params["sk"] = self.session_key From b373de6c68b2577e48d94b3baee557c0a2061d6d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jan 2022 23:27:58 +0200 Subject: [PATCH 678/768] More f-strings --- src/pylast/__init__.py | 73 ++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 2077e0e..36ee0e9 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -216,7 +216,7 @@ class _Network: self.session_key = sk_gen.get_session_key(self.username, self.password_hash) def __str__(self): - return "%s Network" % self.name + return f"{self.name} Network" def get_artist(self, artist_name): """ @@ -276,9 +276,7 @@ class _Network: return self.domain_names[domain_language] def _get_url(self, domain, url_type): - return "https://{}/{}".format( - self._get_language_domain(domain), self.urls[url_type] - ) + return f"https://{self._get_language_domain(domain)}/{self.urls[url_type]}" def _get_ws_auth(self): """ @@ -601,8 +599,8 @@ class _Network: params = {} for i in range(len(tracks_to_scrobble)): - params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"] - params["track[%d]" % i] = tracks_to_scrobble[i]["title"] + params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"] + params[f"track[{i}]"] = tracks_to_scrobble[i]["title"] additional_args = ( "timestamp", @@ -628,7 +626,7 @@ class _Network: else: maps_to = arg - params["%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg] + params[f"{maps_to}[{i}]"] = tracks_to_scrobble[i][arg] _Request(self, "track.scrobble", params).execute() @@ -702,16 +700,14 @@ class LastFMNetwork(_Network): ) def __repr__(self): - return "pylast.LastFMNetwork(%s)" % ( - ", ".join( - ( - "'%s'" % self.api_key, - "'%s'" % self.api_secret, - "'%s'" % self.session_key, - "'%s'" % self.username, - "'%s'" % self.password_hash, - ) - ) + return ( + "pylast.LastFMNetwork(" + f"'{self.api_key}', " + f"'{self.api_secret}', " + f"'{self.session_key}', " + f"'{self.username}', " + f"'{self.password_hash}'" + ")" ) @@ -768,16 +764,14 @@ class LibreFMNetwork(_Network): ) def __repr__(self): - return "pylast.LibreFMNetwork(%s)" % ( - ", ".join( - ( - "'%s'" % self.api_key, - "'%s'" % self.api_secret, - "'%s'" % self.session_key, - "'%s'" % self.username, - "'%s'" % self.password_hash, - ) - ) + return ( + "pylast.LibreFMNetwork(" + f"'{self.api_key}', " + f"'{self.api_secret}', " + f"'{self.session_key}', " + f"'{self.username}', " + f"'{self.password_hash}'" + ")" ) @@ -1018,8 +1012,10 @@ class SessionKeyGenerator: token = self._get_web_auth_token() - url = "{homepage}/api/auth/?api_key={api}&token={token}".format( - homepage=self.network.homepage, api=self.network.api_key, token=token + url = ( + f"{self.network.homepage}/api/auth/" + f"?api_key={self.network.api_key}" + f"&token={token}" ) self.web_auth_tokens[url] = token @@ -1443,8 +1439,9 @@ class MalformedResponseError(PyLastError): self.underlying_error = underlying_error def __str__(self): - return "Malformed response from {}. Underlying error: {}".format( - self.network.name, str(self.underlying_error) + return ( + f"Malformed response from {self.network.name}. " + f"Underlying error: {self.underlying_error}" ) @@ -1456,7 +1453,7 @@ class NetworkError(PyLastError): self.underlying_error = underlying_error def __str__(self): - return "NetworkError: %s" % str(self.underlying_error) + return f"NetworkError: {self.underlying_error}" class _Opus(_Taggable): @@ -1494,16 +1491,14 @@ class _Opus(_Taggable): self.info = info def __repr__(self): - return "pylast.{}({}, {}, {})".format( - self.ws_prefix.title(), - repr(self.artist.name), - repr(self.title), - repr(self.network), + return ( + f"pylast.{self.ws_prefix.title()}" + f"({repr(self.artist.name)}, {repr(self.title)}, {repr(self.network)})" ) @_string_output def __str__(self): - return _unicode("%s - %s") % (self.get_artist().get_name(), self.get_title()) + return f"{self.get_artist().get_name()} - {self.get_title()}" def __eq__(self, other): if type(self) != type(other): @@ -2881,7 +2876,7 @@ def _number(string): def _unescape_htmlentity(string): mapping = html.entities.name2codepoint for key in mapping: - string = string.replace("&%s;" % key, chr(mapping[key])) + string = string.replace(f"&{key};", chr(mapping[key])) return string From 549437b640a2190b78615549bb421fd24ce5522d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 27 Feb 2022 20:49:58 +0200 Subject: [PATCH 679/768] Fix 'a a...' to 'an a...' --- src/pylast/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 36ee0e9..476bca7 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -440,13 +440,13 @@ class _Network: return self.cache_backend is not None def search_for_album(self, album_name): - """Searches for an album by its name. Returns a AlbumSearch object. + """Searches for an album by its name. Returns an AlbumSearch object. Use get_next_page() to retrieve sequences of results.""" return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): - """Searches of an artist by its name. Returns a ArtistSearch object. + """Searches of an artist by its name. Returns an ArtistSearch object. Use get_next_page() to retrieve sequences of results.""" return ArtistSearch(artist_name, self) @@ -976,7 +976,7 @@ class SessionKeyGenerator: A session key's lifetime is infinite, unless the user revokes the rights of the given API Key. - If you create a Network object with just a API_KEY and API_SECRET and a + If you create a Network object with just an API_KEY and API_SECRET and a username and a password_hash, a SESSION_KEY will be automatically generated for that network and stored in it so you don't have to do this manually, unless you want to. From 95c8b1656489efa301c3a181ad50c0baecdca7e2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:20:30 +0300 Subject: [PATCH 680/768] Upgrade Black to fix Click --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23bc55c..9fd1e6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,18 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.31.0 + rev: v2.31.1 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.3.0 hooks: - id: black args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs - rev: v1.12.0 + rev: v1.12.1 hooks: - id: blacken-docs args: [--target-version=py37] @@ -41,7 +41,7 @@ repos: - id: check-yaml - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.0 + rev: v1.20.1 hooks: - id: setup-cfg-fmt From b0f2f5fe1355767686e7233c7417b6b21f0a27cc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:20:48 +0300 Subject: [PATCH 681/768] For some reason the earlier track is returning duration=0 --- tests/test_track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_track.py b/tests/test_track.py index 00f4eca..18f1d87 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -110,7 +110,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): def test_track_get_duration(self): # Arrange - track = pylast.Track("Nirvana", "Lithium", self.network) + track = pylast.Track("Cher", "Believe", self.network) # Act duration = track.get_duration() From 4e5fe31572a34e94c8e8886c7fee495cf362c60a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:38:16 +0300 Subject: [PATCH 682/768] Rename variable e to element --- src/pylast/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 476bca7..51c97f2 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -948,13 +948,13 @@ class _Request: except Exception as e: raise MalformedResponseError(self.network, e) from e - e = doc.getElementsByTagName("lfm")[0] + element = doc.getElementsByTagName("lfm")[0] logger.debug(doc.toprettyxml()) - if e.getAttribute("status") != "ok": - e = doc.getElementsByTagName("error")[0] - status = e.getAttribute("code") - details = e.firstChild.data.strip() + if element.getAttribute("status") != "ok": + element = doc.getElementsByTagName("error")[0] + status = element.getAttribute("code") + details = element.firstChild.data.strip() raise WSError(self.network, status, details) From 6c3f3afb3a2dc7f8a8e736dfeb8df77ef5764e83 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:43:37 +0300 Subject: [PATCH 683/768] autotyping: --none-return: add a -> None return type to functions without any return, yield, or raise in their body --- src/pylast/__init__.py | 78 ++++++++++++++++++++++-------------------- tests/test_album.py | 22 ++++++------ tests/test_artist.py | 40 +++++++++++----------- tests/test_country.py | 4 +-- tests/test_library.py | 10 +++--- tests/test_librefm.py | 4 +-- tests/test_network.py | 60 ++++++++++++++++---------------- tests/test_pylast.py | 20 +++++------ tests/test_tag.py | 8 ++--- tests/test_track.py | 38 ++++++++++---------- tests/test_user.py | 78 +++++++++++++++++++++--------------------- tests/unicode_test.py | 4 +-- 12 files changed, 184 insertions(+), 182 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 51c97f2..231576f 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -156,7 +156,7 @@ class _Network: domain_names, urls, token=None, - ): + ) -> None: """ name: the name of the network homepage: the homepage URL @@ -284,7 +284,7 @@ class _Network: """ return self.api_key, self.api_secret, self.session_key - def _delay_call(self): + def _delay_call(self) -> None: """ Makes sure that web service calls are at least 0.2 seconds apart. """ @@ -496,7 +496,7 @@ class _Network: track_number=None, mbid=None, context=None, - ): + ) -> None: """ Used to notify Last.fm that a user has started listening to a track. @@ -583,7 +583,7 @@ class _Network: ) ) - def scrobble_many(self, tracks): + def scrobble_many(self, tracks) -> None: """ Used to scrobble a batch of tracks at once. The parameter tracks is a sequence of dicts per track containing the keyword arguments as if @@ -664,7 +664,7 @@ class LastFMNetwork(_Network): username="", password_hash="", token="", - ): + ) -> None: super().__init__( name="Last.fm", homepage="https://www.last.fm", @@ -728,7 +728,7 @@ class LibreFMNetwork(_Network): def __init__( self, api_key="", api_secret="", session_key="", username="", password_hash="" - ): + ) -> None: super().__init__( name="Libre.fm", @@ -778,7 +778,7 @@ class LibreFMNetwork(_Network): class _ShelfCacheBackend: """Used as a backend for caching cacheable requests.""" - def __init__(self, file_path=None, flag=None): + def __init__(self, file_path=None, flag=None) -> None: if flag is not None: self.shelf = shelve.open(file_path, flag=flag) else: @@ -794,7 +794,7 @@ class _ShelfCacheBackend: def get_xml(self, key): return self.shelf[key] - def set_xml(self, key, xml_string): + def set_xml(self, key, xml_string) -> None: self.cache_keys.add(key) self.shelf[key] = xml_string @@ -808,7 +808,7 @@ class _ShelfCacheBackend: class _Request: """Representing an abstract web service operation.""" - def __init__(self, network, method_name, params=None): + def __init__(self, network, method_name, params=None) -> None: logger.info(method_name) if params is None: @@ -832,7 +832,7 @@ class _Request: self.params["sk"] = self.session_key self.sign_it() - def sign_it(self): + def sign_it(self) -> None: """Sign this request.""" if "api_sig" not in self.params.keys(): @@ -982,7 +982,7 @@ class SessionKeyGenerator: unless you want to. """ - def __init__(self, network): + def __init__(self, network) -> None: self.network = network self.web_auth_tokens = {} @@ -1094,7 +1094,7 @@ class _BaseObject: network = None - def __init__(self, network, ws_prefix): + def __init__(self, network, ws_prefix) -> None: self.network = network self.ws_prefix = ws_prefix @@ -1194,7 +1194,7 @@ class _BaseObject: class _Chartable(_BaseObject): """Common functions for classes with charts.""" - def __init__(self, network, ws_prefix): + def __init__(self, network, ws_prefix) -> None: super().__init__(network=network, ws_prefix=ws_prefix) def get_weekly_chart_dates(self): @@ -1265,10 +1265,10 @@ class _Chartable(_BaseObject): class _Taggable(_BaseObject): """Common functions for classes with tags.""" - def __init__(self, network, ws_prefix): + def __init__(self, network, ws_prefix) -> None: super().__init__(network=network, ws_prefix=ws_prefix) - def add_tags(self, tags): + def add_tags(self, tags) -> None: """Adds one or several tags. * tags: A sequence of tag names or Tag objects. """ @@ -1276,7 +1276,7 @@ class _Taggable(_BaseObject): for tag in tags: self.add_tag(tag) - def add_tag(self, tag): + def add_tag(self, tag) -> None: """Adds one tag. * tag: a tag name or a Tag object. """ @@ -1289,7 +1289,7 @@ class _Taggable(_BaseObject): self._request(self.ws_prefix + ".addTags", False, params) - def remove_tag(self, tag): + def remove_tag(self, tag) -> None: """Remove a user's tag from this object.""" if isinstance(tag, Tag): @@ -1314,7 +1314,7 @@ class _Taggable(_BaseObject): return tags - def remove_tags(self, tags): + def remove_tags(self, tags) -> None: """Removes one or several tags from this object. * tags: a sequence of tag names or Tag objects. """ @@ -1322,12 +1322,12 @@ class _Taggable(_BaseObject): for tag in tags: self.remove_tag(tag) - def clear_tags(self): + def clear_tags(self) -> None: """Clears all the user-set tags.""" self.remove_tags(*(self.get_tags())) - def set_tags(self, tags): + def set_tags(self, tags) -> None: """Sets this object's tags to only those tags. * tags: a sequence of tag names or Tag objects. """ @@ -1390,7 +1390,7 @@ class PyLastError(Exception): class WSError(PyLastError): """Exception related to the Network web service""" - def __init__(self, network, status, details): + def __init__(self, network, status, details) -> None: self.status = status self.details = details self.network = network @@ -1434,7 +1434,7 @@ class WSError(PyLastError): class MalformedResponseError(PyLastError): """Exception conveying a malformed response from the music network.""" - def __init__(self, network, underlying_error): + def __init__(self, network, underlying_error) -> None: self.network = network self.underlying_error = underlying_error @@ -1448,7 +1448,7 @@ class MalformedResponseError(PyLastError): class NetworkError(PyLastError): """Exception conveying a problem in sending a request to Last.fm""" - def __init__(self, network, underlying_error): + def __init__(self, network, underlying_error) -> None: self.network = network self.underlying_error = underlying_error @@ -1465,7 +1465,9 @@ class _Opus(_Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, artist, title, network, ws_prefix, username=None, info=None): + def __init__( + self, artist, title, network, ws_prefix, username=None, info=None + ) -> None: """ Create an opus instance. # Parameters: @@ -1608,7 +1610,7 @@ class Album(_Opus): __hash__ = _Opus.__hash__ - def __init__(self, artist, title, network, username=None, info=None): + def __init__(self, artist, title, network, username=None, info=None) -> None: super().__init__(artist, title, network, "album", username, info) def get_tracks(self): @@ -1653,7 +1655,7 @@ class Artist(_Taggable): __hash__ = _BaseObject.__hash__ - def __init__(self, name, network, username=None, info=None): + def __init__(self, name, network, username=None, info=None) -> None: """Create an artist object. # Parameters: * name str: The artist's name. @@ -1843,7 +1845,7 @@ class Country(_BaseObject): __hash__ = _BaseObject.__hash__ - def __init__(self, name, network): + def __init__(self, name, network) -> None: super().__init__(network=network, ws_prefix="geo") self.name = name @@ -1918,7 +1920,7 @@ class Library(_BaseObject): __hash__ = _BaseObject.__hash__ - def __init__(self, user, network): + def __init__(self, user, network) -> None: super().__init__(network=network, ws_prefix="library") if isinstance(user, User): @@ -1967,7 +1969,7 @@ class Tag(_Chartable): __hash__ = _BaseObject.__hash__ - def __init__(self, name, network): + def __init__(self, name, network) -> None: super().__init__(network=network, ws_prefix="tag") self.name = name @@ -2054,7 +2056,7 @@ class Track(_Opus): __hash__ = _Opus.__hash__ - def __init__(self, artist, title, network, username=None, info=None): + def __init__(self, artist, title, network, username=None, info=None) -> None: super().__init__(artist, title, network, "track", username, info) def get_correction(self): @@ -2097,12 +2099,12 @@ class Track(_Opus): node = doc.getElementsByTagName("album")[0] return Album(_extract(node, "artist"), _extract(node, "title"), self.network) - def love(self): + def love(self) -> None: """Adds the track to the user's loved tracks.""" self._request(self.ws_prefix + ".love") - def unlove(self): + def unlove(self) -> None: """Remove the track to the user's loved tracks.""" self._request(self.ws_prefix + ".unlove") @@ -2163,7 +2165,7 @@ class User(_Chartable): __hash__ = _BaseObject.__hash__ - def __init__(self, user_name, network): + def __init__(self, user_name, network) -> None: super().__init__(network=network, ws_prefix="user") self.name = user_name @@ -2558,7 +2560,7 @@ class User(_Chartable): class AuthenticatedUser(User): - def __init__(self, network): + def __init__(self, network) -> None: super().__init__(user_name=network.username, network=network) def _get_params(self): @@ -2572,7 +2574,7 @@ class AuthenticatedUser(User): class _Search(_BaseObject): """An abstract class. Use one of its derivatives.""" - def __init__(self, ws_prefix, search_terms, network): + def __init__(self, ws_prefix, search_terms, network) -> None: super().__init__(network, ws_prefix) self._ws_prefix = ws_prefix @@ -2612,7 +2614,7 @@ class _Search(_BaseObject): class AlbumSearch(_Search): """Search for an album by name.""" - def __init__(self, album_name, network): + def __init__(self, album_name, network) -> None: super().__init__( ws_prefix="album", search_terms={"album": album_name}, network=network ) @@ -2639,7 +2641,7 @@ class AlbumSearch(_Search): class ArtistSearch(_Search): """Search for an artist by artist name.""" - def __init__(self, artist_name, network): + def __init__(self, artist_name, network) -> None: super().__init__( ws_prefix="artist", search_terms={"artist": artist_name}, network=network ) @@ -2668,7 +2670,7 @@ class TrackSearch(_Search): down by specifying the artist name, set it to empty string. """ - def __init__(self, artist_name, track_title, network): + def __init__(self, artist_name, track_title, network) -> None: super().__init__( ws_prefix="track", search_terms={"track": track_title, "artist": artist_name}, diff --git a/tests/test_album.py b/tests/test_album.py index 56c469b..ae2c1a0 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -8,7 +8,7 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastAlbum(TestPyLastWithLastFm): - def test_album_tags_are_topitems(self): + def test_album_tags_are_topitems(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") @@ -19,14 +19,14 @@ class TestPyLastAlbum(TestPyLastWithLastFm): assert len(tags) > 0 assert isinstance(tags[0], pylast.TopItem) - def test_album_is_hashable(self): + def test_album_is_hashable(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") # Act/Assert self.helper_is_thing_hashable(album) - def test_album_in_recent_tracks(self): + def test_album_in_recent_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) @@ -37,7 +37,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert assert hasattr(track, "album") - def test_album_wiki_content(self): + def test_album_wiki_content(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -48,7 +48,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): assert wiki is not None assert len(wiki) >= 1 - def test_album_wiki_published_date(self): + def test_album_wiki_published_date(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -59,7 +59,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): assert wiki is not None assert len(wiki) >= 1 - def test_album_wiki_summary(self): + def test_album_wiki_summary(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test Album", self.network) @@ -70,7 +70,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): assert wiki is not None assert len(wiki) >= 1 - def test_album_eq_none_is_false(self): + def test_album_eq_none_is_false(self) -> None: # Arrange album1 = None album2 = pylast.Album("Test Artist", "Test Album", self.network) @@ -78,7 +78,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Act / Assert assert album1 != album2 - def test_album_ne_none_is_true(self): + def test_album_ne_none_is_true(self) -> None: # Arrange album1 = None album2 = pylast.Album("Test Artist", "Test Album", self.network) @@ -86,7 +86,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Act / Assert assert album1 != album2 - def test_get_cover_image(self): + def test_get_cover_image(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") @@ -97,7 +97,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): self.assert_startswith(image, "https://") self.assert_endswith(image, ".gif") - def test_mbid(self): + def test_mbid(self) -> None: # Arrange album = self.network.get_album("Radiohead", "OK Computer") @@ -107,7 +107,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm): # Assert assert mbid == "0b6b4ba0-d36f-47bd-b4ea-6a5b91842d29" - def test_no_mbid(self): + def test_no_mbid(self) -> None: # Arrange album = self.network.get_album("Test Artist", "Test Album") diff --git a/tests/test_artist.py b/tests/test_artist.py index 44bacfd..e72474e 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -10,7 +10,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastArtist(TestPyLastWithLastFm): - def test_repr(self): + def test_repr(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -20,7 +20,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert representation.startswith("pylast.Artist('Test Artist',") - def test_artist_is_hashable(self): + def test_artist_is_hashable(self) -> None: # Arrange test_artist = self.network.get_artist("Radiohead") artist = test_artist.get_similar(limit=2)[0].item @@ -29,7 +29,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Act/Assert self.helper_is_thing_hashable(artist) - def test_bio_published_date(self): + def test_bio_published_date(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -40,7 +40,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert bio is not None assert len(bio) >= 1 - def test_bio_content(self): + def test_bio_content(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -51,7 +51,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert bio is not None assert len(bio) >= 1 - def test_bio_content_none(self): + def test_bio_content_none(self) -> None: # Arrange # An artist with no biography, with "" in the API XML artist = pylast.Artist("Mr Sizef + Unquote", self.network) @@ -62,7 +62,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert bio is None - def test_bio_summary(self): + def test_bio_summary(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) @@ -73,7 +73,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert bio is not None assert len(bio) >= 1 - def test_artist_top_tracks(self): + def test_artist_top_tracks(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item @@ -84,7 +84,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - def test_artist_top_albums(self): + def test_artist_top_albums(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item @@ -107,7 +107,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert len(things) == test_limit - def test_artist_top_albums_limit_default(self): + def test_artist_top_albums_limit_default(self) -> None: # Arrange # Pick an artist with plenty of plays artist = self.network.get_top_artists(limit=1)[0].item @@ -118,7 +118,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert len(things) == 50 - def test_artist_listener_count(self): + def test_artist_listener_count(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") @@ -130,7 +130,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert count > 0 @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_tag_artist(self): + def test_tag_artist(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") # artist.clear_tags() @@ -145,7 +145,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_remove_tag_of_type_text(self): + def test_remove_tag_of_type_text(self) -> None: # Arrange tag = "testing" # text artist = self.network.get_artist("Test Artist") @@ -160,7 +160,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_remove_tag_of_type_tag(self): + def test_remove_tag_of_type_tag(self) -> None: # Arrange tag = pylast.Tag("testing", self.network) # Tag artist = self.network.get_artist("Test Artist") @@ -175,7 +175,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert not found @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_remove_tags(self): + def test_remove_tags(self) -> None: # Arrange tags = ["removetag1", "removetag2"] artist = self.network.get_artist("Test Artist") @@ -195,7 +195,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert not found2 @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_set_tags(self): + def test_set_tags(self) -> None: # Arrange tags = ["sometag1", "sometag2"] artist = self.network.get_artist("Test Artist 2") @@ -219,7 +219,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert found1 assert found2 - def test_artists(self): + def test_artists(self) -> None: # Arrange artist1 = self.network.get_artist("Radiohead") artist2 = self.network.get_artist("Portishead") @@ -239,7 +239,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): assert url == "https://www.last.fm/music/radiohead" assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711" - def test_artist_eq_none_is_false(self): + def test_artist_eq_none_is_false(self) -> None: # Arrange artist1 = None artist2 = pylast.Artist("Test Artist", self.network) @@ -247,7 +247,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Act / Assert assert artist1 != artist2 - def test_artist_ne_none_is_true(self): + def test_artist_ne_none_is_true(self) -> None: # Arrange artist1 = None artist2 = pylast.Artist("Test Artist", self.network) @@ -255,7 +255,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Act / Assert assert artist1 != artist2 - def test_artist_get_correction(self): + def test_artist_get_correction(self) -> None: # Arrange artist = pylast.Artist("guns and roses", self.network) @@ -265,7 +265,7 @@ class TestPyLastArtist(TestPyLastWithLastFm): # Assert assert corrected_artist_name == "Guns N' Roses" - def test_get_userplaycount(self): + def test_get_userplaycount(self) -> None: # Arrange artist = pylast.Artist("John Lennon", self.network, username=self.username) diff --git a/tests/test_country.py b/tests/test_country.py index 4561d82..6d36ef3 100755 --- a/tests/test_country.py +++ b/tests/test_country.py @@ -8,14 +8,14 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastCountry(TestPyLastWithLastFm): - def test_country_is_hashable(self): + def test_country_is_hashable(self) -> None: # Arrange country = self.network.get_country("Italy") # Act/Assert self.helper_is_thing_hashable(country) - def test_countries(self): + def test_countries(self) -> None: # Arrange country1 = pylast.Country("Italy", self.network) country2 = pylast.Country("Finland", self.network) diff --git a/tests/test_library.py b/tests/test_library.py index dea876d..e37b771 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -8,7 +8,7 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastLibrary(TestPyLastWithLastFm): - def test_repr(self): + def test_repr(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -18,7 +18,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm): # Assert self.assert_startswith(representation, "pylast.Library(") - def test_str(self): + def test_str(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) @@ -28,21 +28,21 @@ class TestPyLastLibrary(TestPyLastWithLastFm): # Assert self.assert_endswith(string, "'s Library") - def test_library_is_hashable(self): + def test_library_is_hashable(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) # Act/Assert self.helper_is_thing_hashable(library) - def test_cacheable_library(self): + def test_cacheable_library(self) -> None: # Arrange library = pylast.Library(self.username, self.network) # Act/Assert self.helper_validate_cacheable(library, "get_artists") - def test_get_user(self): + def test_get_user(self) -> None: # Arrange library = pylast.Library(user=self.username, network=self.network) user_to_get = self.network.get_user(self.username) diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 6b0f3dd..0647976 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -13,7 +13,7 @@ from .test_pylast import PyLastTestCase, load_secrets class TestPyLastWithLibreFm(PyLastTestCase): """Own class for Libre.fm because we don't need the Last.fm setUp""" - def test_libre_fm(self): + def test_libre_fm(self) -> None: # Arrange secrets = load_secrets() username = secrets["username"] @@ -27,7 +27,7 @@ class TestPyLastWithLibreFm(PyLastTestCase): # Assert assert name == "Radiohead" - def test_repr(self): + def test_repr(self) -> None: # Arrange secrets = load_secrets() username = secrets["username"] diff --git a/tests/test_network.py b/tests/test_network.py index 8937c53..d10cc66 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -13,7 +13,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastNetwork(TestPyLastWithLastFm): @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_scrobble(self): + def test_scrobble(self) -> None: # Arrange artist = "test artist" title = "test title" @@ -31,7 +31,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert str(last_scrobble.track.title).lower() == title @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_update_now_playing(self): + def test_update_now_playing(self) -> None: # Arrange artist = "Test Artist" title = "test title" @@ -55,7 +55,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert len(current_track.info["image"]) assert re.search(r"^http.+$", current_track.info["image"][pylast.SIZE_LARGE]) - def test_enable_rate_limiting(self): + def test_enable_rate_limiting(self) -> None: # Arrange assert not self.network.is_rate_limited() @@ -72,7 +72,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert self.network.is_rate_limited() assert now - then >= 0.2 - def test_disable_rate_limiting(self): + def test_disable_rate_limiting(self) -> None: # Arrange self.network.enable_rate_limit() assert self.network.is_rate_limited() @@ -87,14 +87,14 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert not self.network.is_rate_limited() - def test_lastfm_network_name(self): + def test_lastfm_network_name(self) -> None: # Act name = str(self.network) # Assert assert name == "Last.fm Network" - def test_geo_get_top_artists(self): + def test_geo_get_top_artists(self) -> None: # Arrange # Act artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1) @@ -104,7 +104,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(artists[0], pylast.TopItem) assert isinstance(artists[0].item, pylast.Artist) - def test_geo_get_top_tracks(self): + def test_geo_get_top_tracks(self) -> None: # Arrange # Act tracks = self.network.get_geo_top_tracks( @@ -116,7 +116,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(tracks[0], pylast.TopItem) assert isinstance(tracks[0].item, pylast.Track) - def test_network_get_top_artists_with_limit(self): + def test_network_get_top_artists_with_limit(self) -> None: # Arrange # Act artists = self.network.get_top_artists(limit=1) @@ -124,7 +124,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - def test_network_get_top_tags_with_limit(self): + def test_network_get_top_tags_with_limit(self) -> None: # Arrange # Act tags = self.network.get_top_tags(limit=1) @@ -132,7 +132,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - def test_network_get_top_tags_with_no_limit(self): + def test_network_get_top_tags_with_no_limit(self) -> None: # Arrange # Act tags = self.network.get_top_tags() @@ -140,7 +140,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag) - def test_network_get_top_tracks_with_limit(self): + def test_network_get_top_tracks_with_limit(self) -> None: # Arrange # Act tracks = self.network.get_top_tracks(limit=1) @@ -148,7 +148,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(tracks, pylast.Track) - def test_country_top_tracks(self): + def test_country_top_tracks(self) -> None: # Arrange country = self.network.get_country("Croatia") @@ -158,7 +158,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - def test_country_network_top_tracks(self): + def test_country_network_top_tracks(self) -> None: # Arrange # Act things = self.network.get_geo_top_tracks("Croatia", limit=2) @@ -166,7 +166,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - def test_tag_top_tracks(self): + def test_tag_top_tracks(self) -> None: # Arrange tag = self.network.get_tag("blues") @@ -176,7 +176,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - def test_album_data(self): + def test_album_data(self) -> None: # Arrange thing = self.network.get_album("Test Artist", "Test Album") @@ -196,7 +196,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert playcount > 1 assert "https://www.last.fm/music/test%2bartist/test%2balbum" == url - def test_track_data(self): + def test_track_data(self) -> None: # Arrange thing = self.network.get_track("Test Artist", "test title") @@ -217,7 +217,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert playcount > 1 assert "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle" == url - def test_country_top_artists(self): + def test_country_top_artists(self) -> None: # Arrange country = self.network.get_country("Ukraine") @@ -227,7 +227,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - def test_caching(self): + def test_caching(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -242,7 +242,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.network.disable_caching() assert not self.network.is_caching_enabled() - def test_album_mbid(self): + def test_album_mbid(self) -> None: # Arrange mbid = "03c91c40-49a6-44a7-90e7-a700edf97a62" @@ -255,7 +255,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert album.title == "Believe" assert album_mbid == mbid - def test_artist_mbid(self): + def test_artist_mbid(self) -> None: # Arrange mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55" @@ -266,7 +266,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(artist, pylast.Artist) assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist") - def test_track_mbid(self): + def test_track_mbid(self) -> None: # Arrange mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0" @@ -279,7 +279,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert track.title == "first" assert track_mbid == mbid - def test_init_with_token(self): + def test_init_with_token(self) -> None: # Arrange/Act msg = None try: @@ -294,7 +294,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert msg == "Unauthorized Token - This token has not been issued" - def test_proxy(self): + def test_proxy(self) -> None: # Arrange proxy = "http://example.com:1234" @@ -306,7 +306,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.network.disable_proxy() assert not self.network.is_proxy_enabled() - def test_album_search(self): + def test_album_search(self) -> None: # Arrange album = "Nevermind" @@ -318,7 +318,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(results, list) assert isinstance(results[0], pylast.Album) - def test_album_search_images(self): + def test_album_search_images(self) -> None: # Arrange album = "Nevermind" search = self.network.search_for_album(album) @@ -338,7 +338,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] - def test_artist_search(self): + def test_artist_search(self) -> None: # Arrange artist = "Nirvana" @@ -350,7 +350,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(results, list) assert isinstance(results[0], pylast.Artist) - def test_artist_search_images(self): + def test_artist_search_images(self) -> None: # Arrange artist = "Nirvana" search = self.network.search_for_artist(artist) @@ -370,7 +370,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] - def test_track_search(self): + def test_track_search(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" @@ -383,7 +383,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): assert isinstance(results, list) assert isinstance(results[0], pylast.Track) - def test_track_search_images(self): + def test_track_search_images(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" @@ -404,7 +404,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm): self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] - def test_search_get_total_result_count(self): + def test_search_get_total_result_count(self) -> None: # Arrange artist = "Nirvana" track = "Smells Like Teen Spirit" diff --git a/tests/test_pylast.py b/tests/test_pylast.py index c7cd7b3..08371d6 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -33,10 +33,10 @@ def load_secrets(): # pragma: no cover class PyLastTestCase: - def assert_startswith(self, s, prefix, start=None, end=None): + def assert_startswith(self, s, prefix, start=None, end=None) -> None: assert s.startswith(prefix, start, end) - def assert_endswith(self, s, suffix, start=None, end=None): + def assert_endswith(self, s, suffix, start=None, end=None) -> None: assert s.endswith(suffix, start, end) @@ -54,7 +54,7 @@ class TestPyLastWithLastFm(PyLastTestCase): return int(time.time()) @classmethod - def setup_class(cls): + def setup_class(cls) -> None: if cls.secrets is None: cls.secrets = load_secrets() @@ -71,7 +71,7 @@ class TestPyLastWithLastFm(PyLastTestCase): password_hash=password_hash, ) - def helper_is_thing_hashable(self, thing): + def helper_is_thing_hashable(self, thing) -> None: # Arrange things = set() @@ -82,7 +82,7 @@ class TestPyLastWithLastFm(PyLastTestCase): assert thing is not None assert len(things) == 1 - def helper_validate_results(self, a, b, c): + def helper_validate_results(self, a, b, c) -> None: # Assert assert a is not None assert b is not None @@ -93,7 +93,7 @@ class TestPyLastWithLastFm(PyLastTestCase): assert a == b assert b == c - def helper_validate_cacheable(self, thing, function_name): + def helper_validate_cacheable(self, thing, function_name) -> None: # Arrange # get thing.function_name() func = getattr(thing, function_name, None) @@ -106,27 +106,27 @@ class TestPyLastWithLastFm(PyLastTestCase): # Assert self.helper_validate_results(result1, result2, result3) - def helper_at_least_one_thing_in_top_list(self, things, expected_type): + def helper_at_least_one_thing_in_top_list(self, things, expected_type) -> None: # Assert assert len(things) > 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) - def helper_only_one_thing_in_top_list(self, things, expected_type): + def helper_only_one_thing_in_top_list(self, things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) - def helper_only_one_thing_in_list(self, things, expected_type): + def helper_only_one_thing_in_list(self, things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], expected_type) - def helper_two_different_things_in_top_list(self, things, expected_type): + def helper_two_different_things_in_top_list(self, things, expected_type) -> None: # Assert assert len(things) == 2 thing1 = things[0] diff --git a/tests/test_tag.py b/tests/test_tag.py index 65544e0..89080f6 100755 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -8,14 +8,14 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastTag(TestPyLastWithLastFm): - def test_tag_is_hashable(self): + def test_tag_is_hashable(self) -> None: # Arrange tag = self.network.get_top_tags(limit=1)[0] # Act/Assert self.helper_is_thing_hashable(tag) - def test_tag_top_artists(self): + def test_tag_top_artists(self) -> None: # Arrange tag = self.network.get_tag("blues") @@ -25,7 +25,7 @@ class TestPyLastTag(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - def test_tag_top_albums(self): + def test_tag_top_albums(self) -> None: # Arrange tag = self.network.get_tag("blues") @@ -35,7 +35,7 @@ class TestPyLastTag(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(albums, pylast.Album) - def test_tags(self): + def test_tags(self) -> None: # Arrange tag1 = self.network.get_tag("blues") tag2 = self.network.get_tag("rock") diff --git a/tests/test_track.py b/tests/test_track.py index 18f1d87..3430e29 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -13,7 +13,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm class TestPyLastTrack(TestPyLastWithLastFm): @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_love(self): + def test_love(self) -> None: # Arrange artist = "Test Artist" title = "test title" @@ -29,7 +29,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert str(loved[0].track.title).lower() == "test title" @pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions") - def test_unlove(self): + def test_unlove(self) -> None: # Arrange artist = pylast.Artist("Test Artist", self.network) title = "test title" @@ -47,7 +47,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert str(loved[0].track.artist) != "Test Artist" assert str(loved[0].track.title) != "test title" - def test_user_play_count_in_track_info(self): + def test_user_play_count_in_track_info(self) -> None: # Arrange artist = "Test Artist" title = "test title" @@ -61,7 +61,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert count >= 0 - def test_user_loved_in_track_info(self): + def test_user_loved_in_track_info(self) -> None: # Arrange artist = "Test Artist" title = "test title" @@ -77,7 +77,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert isinstance(loved, bool) assert not isinstance(loved, str) - def test_track_is_hashable(self): + def test_track_is_hashable(self) -> None: # Arrange artist = self.network.get_artist("Test Artist") track = artist.get_top_tracks(stream=False)[0].item @@ -86,7 +86,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act/Assert self.helper_is_thing_hashable(track) - def test_track_wiki_content(self): + def test_track_wiki_content(self) -> None: # Arrange track = pylast.Track("Test Artist", "test title", self.network) @@ -97,7 +97,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert wiki is not None assert len(wiki) >= 1 - def test_track_wiki_summary(self): + def test_track_wiki_summary(self) -> None: # Arrange track = pylast.Track("Test Artist", "test title", self.network) @@ -108,7 +108,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert wiki is not None assert len(wiki) >= 1 - def test_track_get_duration(self): + def test_track_get_duration(self) -> None: # Arrange track = pylast.Track("Cher", "Believe", self.network) @@ -118,7 +118,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert duration >= 200000 - def test_track_get_album(self): + def test_track_get_album(self) -> None: # Arrange track = pylast.Track("Nirvana", "Lithium", self.network) @@ -128,7 +128,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert str(album) == "Nirvana - Nevermind" - def test_track_get_similar(self): + def test_track_get_similar(self) -> None: # Arrange track = pylast.Track("Cher", "Believe", self.network) @@ -143,7 +143,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): break assert found - def test_track_get_similar_limits(self): + def test_track_get_similar_limits(self) -> None: # Arrange track = pylast.Track("Cher", "Believe", self.network) @@ -153,7 +153,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert len(track.get_similar(limit=None)) >= 23 assert len(track.get_similar(limit=0)) >= 23 - def test_tracks_notequal(self): + def test_tracks_notequal(self) -> None: # Arrange track1 = pylast.Track("Test Artist", "test title", self.network) track2 = pylast.Track("Test Artist", "Test Track", self.network) @@ -162,7 +162,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert track1 != track2 - def test_track_title_prop_caps(self): + def test_track_title_prop_caps(self) -> None: # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -172,7 +172,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert title == "Test Title" - def test_track_listener_count(self): + def test_track_listener_count(self) -> None: # Arrange track = pylast.Track("test artist", "test title", self.network) @@ -182,7 +182,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert count > 21 - def test_album_tracks(self): + def test_album_tracks(self) -> None: # Arrange album = pylast.Album("Test Artist", "Test", self.network) @@ -196,7 +196,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): assert len(tracks) == 1 assert url.startswith("https://www.last.fm/music/test") - def test_track_eq_none_is_false(self): + def test_track_eq_none_is_false(self) -> None: # Arrange track1 = None track2 = pylast.Track("Test Artist", "test title", self.network) @@ -204,7 +204,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act / Assert assert track1 != track2 - def test_track_ne_none_is_true(self): + def test_track_ne_none_is_true(self) -> None: # Arrange track1 = None track2 = pylast.Track("Test Artist", "test title", self.network) @@ -212,7 +212,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Act / Assert assert track1 != track2 - def test_track_get_correction(self): + def test_track_get_correction(self) -> None: # Arrange track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network) @@ -222,7 +222,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert assert corrected_track_name == "Mr. Brownstone" - def test_track_with_no_mbid(self): + def test_track_with_no_mbid(self) -> None: # Arrange track = pylast.Track("Static-X", "Set It Off", self.network) diff --git a/tests/test_user.py b/tests/test_user.py index 5f68262..6e0f3ba 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -16,7 +16,7 @@ from .test_pylast import TestPyLastWithLastFm class TestPyLastUser(TestPyLastWithLastFm): - def test_repr(self): + def test_repr(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -26,7 +26,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.assert_startswith(representation, "pylast.User('RJ',") - def test_str(self): + def test_str(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -36,7 +36,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert string == "RJ" - def test_equality(self): + def test_equality(self) -> None: # Arrange user_1a = self.network.get_user("RJ") user_1b = self.network.get_user("RJ") @@ -48,7 +48,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert user_1a != user_2 assert user_1a != not_a_user - def test_get_name(self): + def test_get_name(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -58,7 +58,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert name == "RJ" - def test_get_user_registration(self): + def test_get_user_registration(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -74,7 +74,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Just check date because of timezones assert "2002-11-20 " in registered - def test_get_user_unixtime_registration(self): + def test_get_user_unixtime_registration(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -85,7 +85,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Just check date because of timezones assert unixtime_registered == 1037793040 - def test_get_countryless_user(self): + def test_get_countryless_user(self) -> None: # Arrange # Currently test_user has no country set: lastfm_user = self.network.get_user("test_user") @@ -96,7 +96,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert country is None - def test_user_get_country(self): + def test_user_get_country(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") @@ -106,7 +106,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert str(country) == "United Kingdom" - def test_user_equals_none(self): + def test_user_equals_none(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) @@ -116,7 +116,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert not value - def test_user_not_equal_to_none(self): + def test_user_not_equal_to_none(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) @@ -126,7 +126,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert value - def test_now_playing_user_with_no_scrobbles(self): + def test_now_playing_user_with_no_scrobbles(self) -> None: # Arrange # Currently test-account has no scrobbles: user = self.network.get_user("test-account") @@ -137,7 +137,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert current_track is None - def test_love_limits(self): + def test_love_limits(self) -> None: # Arrange # Currently test-account has at least 23 loved tracks: user = self.network.get_user("test-user") @@ -148,7 +148,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert len(user.get_loved_tracks(limit=None)) >= 23 assert len(user.get_loved_tracks(limit=0)) >= 23 - def test_user_is_hashable(self): + def test_user_is_hashable(self) -> None: # Arrange user = self.network.get_user(self.username) @@ -169,7 +169,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # # Assert # self.assertGreaterEqual(len(tracks), 0) - def test_pickle(self): + def test_pickle(self) -> None: # Arrange import pickle @@ -187,7 +187,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert lastfm_user == loaded_user @pytest.mark.xfail - def test_cacheable_user(self): + def test_cacheable_user(self) -> None: # Arrange lastfm_user = self.network.get_authenticated_user() @@ -201,7 +201,7 @@ class TestPyLastUser(TestPyLastWithLastFm): lastfm_user, "get_recent_tracks" ) - def test_user_get_top_tags_with_limit(self): + def test_user_get_top_tags_with_limit(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -211,7 +211,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(tags, pylast.Tag) - def test_user_top_tracks(self): + def test_user_top_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") @@ -221,14 +221,14 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_two_different_things_in_top_list(things, pylast.Track) - def helper_assert_chart(self, chart, expected_type): + def helper_assert_chart(self, chart, expected_type) -> None: # Assert assert chart is not None assert len(chart) > 0 assert isinstance(chart[0], pylast.TopItem) assert isinstance(chart[0].item, expected_type) - def helper_get_assert_charts(self, thing, date): + def helper_get_assert_charts(self, thing, date) -> None: # Arrange album_chart, track_chart = None, None (from_date, to_date) = date @@ -245,14 +245,14 @@ class TestPyLastUser(TestPyLastWithLastFm): self.helper_assert_chart(album_chart, pylast.Album) self.helper_assert_chart(track_chart, pylast.Track) - def helper_dates_valid(self, dates): + def helper_dates_valid(self, dates) -> None: # Assert assert len(dates) >= 1 assert isinstance(dates[0], tuple) (start, end) = dates[0] assert start < end - def test_user_charts(self): + def test_user_charts(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") dates = lastfm_user.get_weekly_chart_dates() @@ -261,7 +261,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Act/Assert self.helper_get_assert_charts(lastfm_user, dates[0]) - def test_user_top_artists(self): + def test_user_top_artists(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) @@ -271,7 +271,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_top_list(artists, pylast.Artist) - def test_user_top_albums(self): + def test_user_top_albums(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -285,7 +285,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert len(top_album.info["image"]) assert re.search(r"^http.+$", top_album.info["image"][pylast.SIZE_LARGE]) - def test_user_tagged_artists(self): + def test_user_tagged_artists(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["artisttagola"] @@ -298,7 +298,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_list(artists, pylast.Artist) - def test_user_tagged_albums(self): + def test_user_tagged_albums(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["albumtagola"] @@ -311,7 +311,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_list(albums, pylast.Album) - def test_user_tagged_tracks(self): + def test_user_tagged_tracks(self) -> None: # Arrange lastfm_user = self.network.get_user(self.username) tags = ["tracktagola"] @@ -324,7 +324,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.helper_only_one_thing_in_list(tracks, pylast.Track) - def test_user_subscriber(self): + def test_user_subscriber(self) -> None: # Arrange subscriber = self.network.get_user("RJ") non_subscriber = self.network.get_user("Test User") @@ -337,7 +337,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert subscriber_is_subscriber assert not non_subscriber_is_subscriber - def test_user_get_image(self): + def test_user_get_image(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -347,7 +347,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert self.assert_startswith(url, "https://") - def test_user_get_library(self): + def test_user_get_library(self) -> None: # Arrange user = self.network.get_user(self.username) @@ -357,7 +357,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert isinstance(library, pylast.Library) - def test_get_recent_tracks_from_to(self): + def test_get_recent_tracks_from_to(self) -> None: # Arrange lastfm_user = self.network.get_user("RJ") start = dt.datetime(2011, 7, 21, 15, 10) @@ -374,7 +374,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert str(tracks[0].track.artist) == "Johnny Cash" assert str(tracks[0].track.title) == "Ring of Fire" - def test_get_recent_tracks_limit_none(self): + def test_get_recent_tracks_limit_none(self) -> None: # Arrange lastfm_user = self.network.get_user("bbc6music") start = dt.datetime(2020, 2, 15, 15, 00) @@ -393,7 +393,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert str(tracks[0].track.artist) == "Seun Kuti & Egypt 80" assert str(tracks[0].track.title) == "Struggles Sounds" - def test_get_recent_tracks_is_streamable(self): + def test_get_recent_tracks_is_streamable(self) -> None: # Arrange lastfm_user = self.network.get_user("bbc6music") start = dt.datetime(2020, 2, 15, 15, 00) @@ -410,7 +410,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert inspect.isgenerator(tracks) - def test_get_playcount(self): + def test_get_playcount(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -420,7 +420,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert playcount >= 128387 - def test_get_image(self): + def test_get_image(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -431,7 +431,7 @@ class TestPyLastUser(TestPyLastWithLastFm): self.assert_startswith(image, "https://") self.assert_endswith(image, ".png") - def test_get_url(self): + def test_get_url(self) -> None: # Arrange user = self.network.get_user("RJ") @@ -441,7 +441,7 @@ class TestPyLastUser(TestPyLastWithLastFm): # Assert assert url == "https://www.last.fm/user/rj" - def test_get_weekly_artist_charts(self): + def test_get_weekly_artist_charts(self) -> None: # Arrange user = self.network.get_user("bbc6music") @@ -453,7 +453,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert artist is not None assert isinstance(artist.network, pylast.LastFMNetwork) - def test_get_weekly_track_charts(self): + def test_get_weekly_track_charts(self) -> None: # Arrange user = self.network.get_user("bbc6music") @@ -465,7 +465,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert track is not None assert isinstance(track.network, pylast.LastFMNetwork) - def test_user_get_track_scrobbles(self): + def test_user_get_track_scrobbles(self) -> None: # Arrange artist = "France Gall" title = "Laisse Tomber Les Filles" @@ -479,7 +479,7 @@ class TestPyLastUser(TestPyLastWithLastFm): assert str(scrobbles[0].track.artist) == "France Gall" assert scrobbles[0].track.title == "Laisse Tomber Les Filles" - def test_cacheable_user_get_track_scrobbles(self): + def test_cacheable_user_get_track_scrobbles(self) -> None: # Arrange artist = "France Gall" title = "Laisse Tomber Les Filles" diff --git a/tests/unicode_test.py b/tests/unicode_test.py index 350256c..bc93dfa 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -18,13 +18,13 @@ def mock_network(): "fdasfdsafsaf not unicode", ], ) -def test_get_cache_key(artist): +def test_get_cache_key(artist) -> None: request = pylast._Request(mock_network(), "some_method", params={"artist": artist}) request._get_cache_key() @pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())]) -def test_cast_and_hash(obj): +def test_cast_and_hash(obj) -> None: assert type(str(obj)) is str assert isinstance(hash(obj), int) From eb4af40d641c965ab595def7eb69f245675e78f5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:45:23 +0300 Subject: [PATCH 684/768] autotyping: --scalar-return: add a return annotation to functions that only return literal bool, str, bytes, int, or float objects --- src/pylast/__init__.py | 26 +++++++++++++------------- tests/test_pylast.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 231576f..f33ac54 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -215,7 +215,7 @@ class _Network: sk_gen = SessionKeyGenerator(self) self.session_key = sk_gen.get_session_key(self.username, self.password_hash) - def __str__(self): + def __str__(self) -> str: return f"{self.name} Network" def get_artist(self, artist_name): @@ -275,7 +275,7 @@ class _Network: if domain_language in self.domain_names: return self.domain_names[domain_language] - def _get_url(self, domain, url_type): + def _get_url(self, domain, url_type) -> str: return f"https://{self._get_language_domain(domain)}/{self.urls[url_type]}" def _get_ws_auth(self): @@ -699,7 +699,7 @@ class LastFMNetwork(_Network): }, ) - def __repr__(self): + def __repr__(self) -> str: return ( "pylast.LastFMNetwork(" f"'{self.api_key}', " @@ -763,7 +763,7 @@ class LibreFMNetwork(_Network): }, ) - def __repr__(self): + def __repr__(self) -> str: return ( "pylast.LibreFMNetwork(" f"'{self.api_key}', " @@ -1438,7 +1438,7 @@ class MalformedResponseError(PyLastError): self.network = network self.underlying_error = underlying_error - def __str__(self): + def __str__(self) -> str: return ( f"Malformed response from {self.network.name}. " f"Underlying error: {self.underlying_error}" @@ -1452,7 +1452,7 @@ class NetworkError(PyLastError): self.network = network self.underlying_error = underlying_error - def __str__(self): + def __str__(self) -> str: return f"NetworkError: {self.underlying_error}" @@ -1492,14 +1492,14 @@ class _Opus(_Taggable): ) # Default to current user self.info = info - def __repr__(self): + def __repr__(self) -> str: return ( f"pylast.{self.ws_prefix.title()}" f"({repr(self.artist.name)}, {repr(self.title)}, {repr(self.network)})" ) @_string_output - def __str__(self): + def __str__(self) -> str: return f"{self.get_artist().get_name()} - {self.get_title()}" def __eq__(self, other): @@ -1670,7 +1670,7 @@ class Artist(_Taggable): self.username = username self.info = info - def __repr__(self): + def __repr__(self) -> str: return f"pylast.Artist({repr(self.get_name())}, {repr(self.network)})" def __unicode__(self): @@ -1850,7 +1850,7 @@ class Country(_BaseObject): self.name = name - def __repr__(self): + def __repr__(self) -> str: return f"pylast.Country({repr(self.name)}, {repr(self.network)})" @_string_output @@ -1928,7 +1928,7 @@ class Library(_BaseObject): else: self.user = User(user, self.network) - def __repr__(self): + def __repr__(self) -> str: return f"pylast.Library({repr(self.user)}, {repr(self.network)})" @_string_output @@ -1974,7 +1974,7 @@ class Tag(_Chartable): self.name = name - def __repr__(self): + def __repr__(self) -> str: return f"pylast.Tag({repr(self.name)}, {repr(self.network)})" @_string_output @@ -2170,7 +2170,7 @@ class User(_Chartable): self.name = user_name - def __repr__(self): + def __repr__(self) -> str: return f"pylast.User({repr(self.name)}, {repr(self.network)})" @_string_output diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 08371d6..7125413 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -40,7 +40,7 @@ class PyLastTestCase: assert s.endswith(suffix, start, end) -def _no_xfail_rerun_filter(err, name, test, plugin): +def _no_xfail_rerun_filter(err, name, test, plugin) -> bool: for _ in test.iter_markers(name="xfail"): return False From 5ab3e53a44c7962d3a5df805c1b7f84c65553a9a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:46:14 +0300 Subject: [PATCH 685/768] autotyping: --bool-param: add a : bool annotation to any function parameter with a default of True or False --- src/pylast/__init__.py | 81 +++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index f33ac54..70280b6 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -297,7 +297,7 @@ class _Network: self.last_call_time = now - def get_top_artists(self, limit=None, cacheable=True): + def get_top_artists(self, limit=None, cacheable: bool = True): """Returns the most played artists as a sequence of TopItem objects.""" params = {} @@ -308,7 +308,7 @@ class _Network: return _extract_top_artists(doc, self) - def get_top_tracks(self, limit=None, cacheable=True): + def get_top_tracks(self, limit=None, cacheable: bool = True): """Returns the most played tracks as a sequence of TopItem objects.""" params = {} @@ -327,7 +327,7 @@ class _Network: return seq - def get_top_tags(self, limit=None, cacheable=True): + def get_top_tags(self, limit=None, cacheable: bool = True): """Returns the most used tags as a sequence of TopItem objects.""" # Last.fm has no "limit" parameter for tag.getTopTags @@ -344,7 +344,7 @@ class _Network: return seq - def get_geo_top_artists(self, country, limit=None, cacheable=True): + def get_geo_top_artists(self, country, limit=None, cacheable: bool = True): """Get the most popular artists on Last.fm by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 @@ -361,7 +361,9 @@ class _Network: return _extract_top_artists(doc, self) - def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True): + def get_geo_top_tracks( + self, country, location=None, limit=None, cacheable: bool = True + ): """Get the most popular tracks on Last.fm last week by country. Parameters: country (Required) : A country name, as defined by the ISO 3166-1 @@ -1098,7 +1100,7 @@ class _BaseObject: self.network = network self.ws_prefix = ws_prefix - def _request(self, method_name, cacheable=False, params=None): + def _request(self, method_name, cacheable: bool = False, params=None): if not params: params = self._get_params() @@ -1129,7 +1131,12 @@ class _BaseObject: return first_child.wholeText.strip() def _get_things( - self, method, thing_type, params=None, cacheable=True, stream=False + self, + method, + thing_type, + params=None, + cacheable: bool = True, + stream: bool = False, ): """Returns a list of the most played thing_types by this thing.""" @@ -1540,7 +1547,7 @@ class _Opus(_Taggable): ) return self.info["image"][size] - def get_title(self, properly_capitalized=False): + def get_title(self, properly_capitalized: bool = False): """Returns the artist or track title.""" if properly_capitalized: self.title = _extract( @@ -1549,7 +1556,7 @@ class _Opus(_Taggable): return self.title - def get_name(self, properly_capitalized=False): + def get_name(self, properly_capitalized: bool = False): """Returns the album or track title (alias to get_title()).""" return self.get_title(properly_capitalized) @@ -1692,7 +1699,7 @@ class Artist(_Taggable): def _get_params(self): return {self.ws_prefix: self.get_name()} - def get_name(self, properly_capitalized=False): + def get_name(self, properly_capitalized: bool = False): """Returns the name of the artist. If properly_capitalized was asserted then the name would be downloaded overwriting the given one.""" @@ -1799,7 +1806,7 @@ class Artist(_Taggable): return artists - def get_top_albums(self, limit=None, cacheable=True, stream=False): + def get_top_albums(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the top albums.""" params = self._get_params() if limit: @@ -1807,7 +1814,7 @@ class Artist(_Taggable): return self._get_things("getTopAlbums", Album, params, cacheable, stream=stream) - def get_top_tracks(self, limit=None, cacheable=True, stream=False): + def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the most played Tracks by this artist.""" params = self._get_params() if limit: @@ -1871,7 +1878,7 @@ class Country(_BaseObject): return self.name - def get_top_artists(self, limit=None, cacheable=True): + def get_top_artists(self, limit=None, cacheable: bool = True): """Returns a sequence of the most played artists.""" params = self._get_params() if limit: @@ -1881,7 +1888,7 @@ class Country(_BaseObject): return _extract_top_artists(doc, self) - def get_top_tracks(self, limit=None, cacheable=True, stream=False): + def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a sequence of the most played tracks""" params = self._get_params() if limit: @@ -1942,7 +1949,7 @@ class Library(_BaseObject): """Returns the user who owns this library.""" return self.user - def get_artists(self, limit=50, cacheable=True, stream=False): + def get_artists(self, limit=50, cacheable: bool = True, stream: bool = False): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) @@ -1990,7 +1997,7 @@ class Tag(_Chartable): def _get_params(self): return {self.ws_prefix: self.get_name()} - def get_name(self, properly_capitalized=False): + def get_name(self, properly_capitalized: bool = False): """Returns the name of the tag.""" if properly_capitalized: @@ -2000,7 +2007,7 @@ class Tag(_Chartable): return self.name - def get_top_albums(self, limit=None, cacheable=True): + def get_top_albums(self, limit=None, cacheable: bool = True): """Returns a list of the top albums.""" params = self._get_params() if limit: @@ -2010,7 +2017,7 @@ class Tag(_Chartable): return _extract_top_albums(doc, self.network) - def get_top_tracks(self, limit=None, cacheable=True, stream=False): + def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False): """Returns a list of the most played Tracks for this tag.""" params = self._get_params() if limit: @@ -2018,7 +2025,7 @@ class Tag(_Chartable): return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) - def get_top_artists(self, limit=None, cacheable=True): + def get_top_artists(self, limit=None, cacheable: bool = True): """Returns a sequence of the most played artists.""" params = self._get_params() @@ -2199,7 +2206,7 @@ class User(_Chartable): Track(track_artist, title, self.network), album, date, timestamp ) - def get_name(self, properly_capitalized=False): + def get_name(self, properly_capitalized: bool = False): """Returns the user name.""" if properly_capitalized: @@ -2209,7 +2216,7 @@ class User(_Chartable): return self.name - def get_friends(self, limit=50, cacheable=False, stream=False): + def get_friends(self, limit=50, cacheable: bool = False, stream: bool = False): """Returns a list of the user's friends.""" def _get_friends(): @@ -2220,7 +2227,7 @@ class User(_Chartable): return _get_friends() if stream else list(_get_friends()) - def get_loved_tracks(self, limit=50, cacheable=True, stream=False): + def get_loved_tracks(self, limit=50, cacheable: bool = True, stream: bool = False): """ Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. @@ -2286,11 +2293,11 @@ class User(_Chartable): def get_recent_tracks( self, limit=10, - cacheable=True, + cacheable: bool = True, time_from=None, time_to=None, - stream=False, - now_playing=False, + stream: bool = False, + now_playing: bool = False, ): """ Returns this user's played track as a sequence of PlayedTrack objects @@ -2380,7 +2387,7 @@ class User(_Chartable): return int(doc.getElementsByTagName("registered")[0].getAttribute("unixtime")) - def get_tagged_albums(self, tag, limit=None, cacheable=True): + def get_tagged_albums(self, tag, limit=None, cacheable: bool = True): """Returns the albums tagged by a user.""" params = self._get_params() @@ -2402,7 +2409,7 @@ class User(_Chartable): doc = self._request(self.ws_prefix + ".getpersonaltags", True, params) return _extract_artists(doc, self.network) - def get_tagged_tracks(self, tag, limit=None, cacheable=True): + def get_tagged_tracks(self, tag, limit=None, cacheable: bool = True): """Returns the tracks tagged by a user.""" params = self._get_params() @@ -2413,7 +2420,7 @@ class User(_Chartable): doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params) return _extract_tracks(doc, self.network) - def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable=True): + def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable: bool = True): """Returns the top albums played by a user. * period: The period of time. Possible values: o PERIOD_OVERALL @@ -2453,7 +2460,7 @@ class User(_Chartable): return _extract_top_artists(doc, self.network) - def get_top_tags(self, limit=None, cacheable=True): + def get_top_tags(self, limit=None, cacheable: bool = True): """ Returns a sequence of the top tags used by this user with their counts as TopItem objects. @@ -2478,7 +2485,11 @@ class User(_Chartable): return seq def get_top_tracks( - self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=False + self, + period=PERIOD_OVERALL, + limit=None, + cacheable: bool = True, + stream: bool = False, ): """Returns the top tracks played by a user. * period: The period of time. Possible values: @@ -2496,7 +2507,9 @@ class User(_Chartable): return self._get_things("getTopTracks", Track, params, cacheable, stream=stream) - def get_track_scrobbles(self, artist, track, cacheable=False, stream=False): + def get_track_scrobbles( + self, artist, track, cacheable: bool = False, stream: bool = False + ): """ Get a list of this user's scrobbles of this artist's track, including scrobble time. @@ -2566,7 +2579,7 @@ class AuthenticatedUser(User): def _get_params(self): return {"user": self.get_name()} - def get_name(self, properly_capitalized=False): + def get_name(self, properly_capitalized: bool = False): """Returns the name of the authenticated user.""" return super().get_name(properly_capitalized=properly_capitalized) @@ -2722,7 +2735,9 @@ def cleanup_nodes(doc): return doc -def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=False): +def _collect_nodes( + limit, sender, method_name, cacheable, params=None, stream: bool = False +): """ Returns a sequence of dom.Node objects about as close to limit as possible """ From 54ea354a7a510d44c26b04123e1b90d3a65ab91e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:47:10 +0300 Subject: [PATCH 686/768] autotyping: --int-param, --float-param, --str-param, --bytes-param: add an annotation to any parameter for which the default is a literal int, float, str, or bytes object --- src/pylast/__init__.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 70280b6..2a48fab 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -660,12 +660,12 @@ class LastFMNetwork(_Network): def __init__( self, - api_key="", - api_secret="", - session_key="", - username="", - password_hash="", - token="", + api_key: str = "", + api_secret: str = "", + session_key: str = "", + username: str = "", + password_hash: str = "", + token: str = "", ) -> None: super().__init__( name="Last.fm", @@ -729,7 +729,12 @@ class LibreFMNetwork(_Network): """ def __init__( - self, api_key="", api_secret="", session_key="", username="", password_hash="" + self, + api_key: str = "", + api_secret: str = "", + session_key: str = "", + username: str = "", + password_hash: str = "", ) -> None: super().__init__( @@ -1024,7 +1029,7 @@ class SessionKeyGenerator: return url - def get_web_auth_session_key_username(self, url, token=""): + def get_web_auth_session_key_username(self, url, token: str = ""): """ Retrieves the session key/username of a web authorization process by its URL. """ @@ -1044,7 +1049,7 @@ class SessionKeyGenerator: username = doc.getElementsByTagName("name")[0].firstChild.data return session_key, username - def get_web_auth_session_key(self, url, token=""): + def get_web_auth_session_key(self, url, token: str = ""): """ Retrieves the session key of a web authorization process by its URL. """ @@ -1949,7 +1954,9 @@ class Library(_BaseObject): """Returns the user who owns this library.""" return self.user - def get_artists(self, limit=50, cacheable: bool = True, stream: bool = False): + def get_artists( + self, limit: int = 50, cacheable: bool = True, stream: bool = False + ): """ Returns a sequence of Album objects if limit==None it will return all (may take a while) @@ -2216,7 +2223,9 @@ class User(_Chartable): return self.name - def get_friends(self, limit=50, cacheable: bool = False, stream: bool = False): + def get_friends( + self, limit: int = 50, cacheable: bool = False, stream: bool = False + ): """Returns a list of the user's friends.""" def _get_friends(): @@ -2227,7 +2236,9 @@ class User(_Chartable): return _get_friends() if stream else list(_get_friends()) - def get_loved_tracks(self, limit=50, cacheable: bool = True, stream: bool = False): + def get_loved_tracks( + self, limit: int = 50, cacheable: bool = True, stream: bool = False + ): """ Returns this user's loved track as a sequence of LovedTrack objects in reverse order of their timestamp, all the way back to the first track. @@ -2292,7 +2303,7 @@ class User(_Chartable): def get_recent_tracks( self, - limit=10, + limit: int = 10, cacheable: bool = True, time_from=None, time_to=None, @@ -2792,7 +2803,7 @@ def _collect_nodes( return _stream_collect_nodes() if stream else list(_stream_collect_nodes()) -def _extract(node, name, index=0): +def _extract(node, name, index: int = 0): """Extracts a value from the xml string""" nodes = node.getElementsByTagName(name) From 7b9c73acb70386e086525b4de78b1fa055451f86 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:47:58 +0300 Subject: [PATCH 687/768] autotyping: --annotate-magics: add type annotation to certain magic methods --- src/pylast/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 2a48fab..3ed1604 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -792,7 +792,7 @@ class _ShelfCacheBackend: self.shelf = shelve.open(file_path) self.cache_keys = set(self.shelf.keys()) - def __contains__(self, key): + def __contains__(self, key) -> bool: return key in self.cache_keys def __iter__(self): @@ -1408,7 +1408,7 @@ class WSError(PyLastError): self.network = network @_string_output - def __str__(self): + def __str__(self) -> str: return self.details def get_id(self): @@ -1689,7 +1689,7 @@ class Artist(_Taggable): return str(self.get_name()) @_string_output - def __str__(self): + def __str__(self) -> str: return self.__unicode__() def __eq__(self, other): @@ -1866,7 +1866,7 @@ class Country(_BaseObject): return f"pylast.Country({repr(self.name)}, {repr(self.network)})" @_string_output - def __str__(self): + def __str__(self) -> str: return self.get_name() def __eq__(self, other): @@ -1944,7 +1944,7 @@ class Library(_BaseObject): return f"pylast.Library({repr(self.user)}, {repr(self.network)})" @_string_output - def __str__(self): + def __str__(self) -> str: return repr(self.get_user()) + "'s Library" def _get_params(self): @@ -1992,7 +1992,7 @@ class Tag(_Chartable): return f"pylast.Tag({repr(self.name)}, {repr(self.network)})" @_string_output - def __str__(self): + def __str__(self) -> str: return self.get_name() def __eq__(self, other): @@ -2188,7 +2188,7 @@ class User(_Chartable): return f"pylast.User({repr(self.name)}, {repr(self.network)})" @_string_output - def __str__(self): + def __str__(self) -> str: return self.get_name() def __eq__(self, other): From 14e091c8706881acaf88f0a19a86e84ffaee1f9b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:49:01 +0300 Subject: [PATCH 688/768] autotyping: --annotate-imprecise-magics: add imprecise type annotations for some additional magic methods --- src/pylast/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 3ed1604..162d0f1 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -31,6 +31,7 @@ import ssl import tempfile import time import xml.dom +from typing import Iterator from urllib.parse import quote_plus from xml.dom import Node, minidom @@ -795,7 +796,7 @@ class _ShelfCacheBackend: def __contains__(self, key) -> bool: return key in self.cache_keys - def __iter__(self): + def __iter__(self) -> Iterator: return iter(self.shelf.keys()) def get_xml(self, key): From ac991cbd2c4fc623bc0cc19894eb49ebeef47343 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:58:44 +0300 Subject: [PATCH 689/768] Types and typos --- src/pylast/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 162d0f1..4b27806 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -42,7 +42,7 @@ try: import importlib.metadata as importlib_metadata except ImportError: # Python 3.7 and lower - import importlib_metadata + import importlib_metadata # type: ignore __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai" @@ -197,7 +197,7 @@ class _Network: self.cache_backend = None self.proxy = None - self.last_call_time = 0 + self.last_call_time: float = 0 self.limit_rate = False # Load session_key and username from authentication token if provided @@ -335,7 +335,7 @@ class _Network: # so we need to get all (250) and then limit locally doc = _Request(self, "tag.getTopTags").execute(cacheable) - seq = [] + seq: list[TopItem] = [] for node in doc.getElementsByTagName("tag"): if limit and len(seq) >= limit: break @@ -449,13 +449,13 @@ class _Network: return AlbumSearch(album_name, self) def search_for_artist(self, artist_name): - """Searches of an artist by its name. Returns an ArtistSearch object. + """Searches for an artist by its name. Returns an ArtistSearch object. Use get_next_page() to retrieve sequences of results.""" return ArtistSearch(artist_name, self) def search_for_track(self, artist_name, track_name): - """Searches of a track by its name and its artist. Set artist to an + """Searches for a track by its name and its artist. Set artist to an empty string if not available. Returns a TrackSearch object. Use get_next_page() to retrieve sequences of results.""" From 4f37ba41bdcc598f6df27d2555c2dde6a2105dd1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 18:11:42 +0300 Subject: [PATCH 690/768] Initialise float as 0.0 And skip Iterator type for now to avoid its complex subscripting --- src/pylast/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 4b27806..e152c31 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -31,7 +31,6 @@ import ssl import tempfile import time import xml.dom -from typing import Iterator from urllib.parse import quote_plus from xml.dom import Node, minidom @@ -197,7 +196,7 @@ class _Network: self.cache_backend = None self.proxy = None - self.last_call_time: float = 0 + self.last_call_time: float = 0.0 self.limit_rate = False # Load session_key and username from authentication token if provided @@ -796,7 +795,7 @@ class _ShelfCacheBackend: def __contains__(self, key) -> bool: return key in self.cache_keys - def __iter__(self) -> Iterator: + def __iter__(self): return iter(self.shelf.keys()) def get_xml(self, key): From c1a8a9455f0efd4a64a64ff7766417e22f1281f5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Apr 2022 17:19:23 +0000 Subject: [PATCH 691/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.31.0 → v2.31.1](https://github.com/asottile/pyupgrade/compare/v2.31.0...v2.31.1) - [github.com/psf/black: 21.12b0 → 22.3.0](https://github.com/psf/black/compare/21.12b0...22.3.0) - [github.com/asottile/blacken-docs: v1.12.0 → v1.12.1](https://github.com/asottile/blacken-docs/compare/v1.12.0...v1.12.1) - [github.com/asottile/setup-cfg-fmt: v1.20.0 → v1.20.1](https://github.com/asottile/setup-cfg-fmt/compare/v1.20.0...v1.20.1) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23bc55c..9fd1e6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,18 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.31.0 + rev: v2.31.1 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.3.0 hooks: - id: black args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs - rev: v1.12.0 + rev: v1.12.1 hooks: - id: blacken-docs args: [--target-version=py37] @@ -41,7 +41,7 @@ repos: - id: check-yaml - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.0 + rev: v1.20.1 hooks: - id: setup-cfg-fmt From 2478980ca50c0576e8866271f9cef194672c9672 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 3 Apr 2022 12:20:48 +0300 Subject: [PATCH 692/768] For some reason the earlier track is returning duration=0 --- tests/test_track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_track.py b/tests/test_track.py index 00f4eca..18f1d87 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -110,7 +110,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): def test_track_get_duration(self): # Arrange - track = pylast.Track("Nirvana", "Lithium", self.network) + track = pylast.Track("Cher", "Believe", self.network) # Act duration = track.get_duration() From 5b0c879fa0e1510a7b3287f6ae0fbf14424769a2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 12:02:25 +0300 Subject: [PATCH 693/768] Update config --- .github/workflows/deploy.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/require-pr-label.yml | 18 ++++++++++++++++++ .github/workflows/test.yml | 9 +++------ setup.py | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/require-pr-label.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5bd3973..5002205 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,7 +12,7 @@ on: jobs: deploy: if: github.repository_owner == 'pylast' - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -24,7 +24,7 @@ jobs: with: python-version: "3.10" cache: pip - cache-dependency-path: "setup.py" + cache-dependency-path: setup.cfg - name: Install dependencies run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d9014a8..b095963 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,7 +4,7 @@ on: [push, pull_request, workflow_dispatch] jobs: lint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml new file mode 100644 index 0000000..a2c74d5 --- /dev/null +++ b/.github/workflows/require-pr-label.yml @@ -0,0 +1,18 @@ +name: Require PR label + +on: + pull_request: + types: [opened, reopened, labeled, unlabeled, synchronize] + +jobs: + label: + runs-on: ubuntu-latest + + steps: + - uses: mheap/github-action-required-labels@v1 + with: + mode: minimum + count: 1 + labels: + "changelog: Added, changelog: Changed, changelog: Deprecated, changelog: + Fixed, changelog: Removed, changelog: Security, changelog: skip" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9dc716..199d2e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,10 +12,7 @@ jobs: fail-fast: false matrix: python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10"] - os: [ubuntu-20.04] - include: - # Include new variables for Codecov - - { codecov-flag: GHA_Ubuntu2004, os: ubuntu-20.04 } + os: [ubuntu-latest] steps: - uses: actions/checkout@v2 @@ -25,7 +22,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip - cache-dependency-path: "setup.py" + cache-dependency-path: setup.cfg - name: Install dependencies run: | @@ -45,5 +42,5 @@ jobs: - name: Upload coverage uses: codecov/codecov-action@v2 with: - flags: ${{ matrix.codecov-flag }} + flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index 668794a..630d39e 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup -def local_scheme(version) -> str: +def local_scheme(version: str) -> str: """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) to be able to upload to Test PyPI""" return "" From fa94ed02635817bb62f246ac6694f5282cf46710 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 12:04:58 +0300 Subject: [PATCH 694/768] Support Python 3.11 --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 5 +++-- setup.cfg | 1 + tox.ini | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 199d2e6..c118faa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10"] + python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11-dev"] os: [ubuntu-latest] steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9fd1e6d..b09254b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v2.32.0 hooks: - id: pyupgrade args: [--py37-plus] @@ -35,7 +35,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: check-merge-conflict - id: check-yaml @@ -44,6 +44,7 @@ repos: rev: v1.20.1 hooks: - id: setup-cfg-fmt + args: [--max-py-version=3.11] - repo: https://github.com/tox-dev/tox-ini-fmt rev: 0.5.2 diff --git a/setup.cfg b/setup.cfg index a48f245..5fa2a3d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: PyPy Topic :: Internet diff --git a/tox.ini b/tox.ini index 48e8339..0f07848 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = lint - py{py3, 310, 39, 38, 37} + py{py3, 311, 310, 39, 38, 37} [testenv] passenv = From dec407d95875beef6c95cae7cc577a2bf1cf02fd Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 12:23:31 +0300 Subject: [PATCH 695/768] Add final 'Test successful' to simplify PR status check requirements --- .github/workflows/test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c118faa..6a63739 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,3 +44,11 @@ jobs: with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} + + success: + needs: test + runs-on: ubuntu-latest + name: test successful + steps: + - name: Success + run: echo Test successful From afbafe1e764d88c2148730fb19d1cba7144f7a25 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 15:06:07 +0300 Subject: [PATCH 696/768] Fix test --- tests/test_track.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index 3430e29..70d2044 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -110,13 +110,13 @@ class TestPyLastTrack(TestPyLastWithLastFm): def test_track_get_duration(self) -> None: # Arrange - track = pylast.Track("Cher", "Believe", self.network) + track = pylast.Track("Radiohead", "Creep", self.network) # Act duration = track.get_duration() # Assert - assert duration >= 200000 + assert duration >= 100000 def test_track_get_album(self) -> None: # Arrange From ea421db602b39bff2f8bdcfb50c704cc84827210 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 2 May 2022 12:00:21 +0000 Subject: [PATCH 697/768] chore(deps): add renovate.json --- renovate.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f45d8f1 --- /dev/null +++ b/renovate.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "config:base" + ] +} From 861182253c77473c2493df5e86d685b84f49635a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 15:13:39 +0300 Subject: [PATCH 698/768] Move to .github and add labels --- .github/renovate.json | 9 +++++++++ .pre-commit-config.yaml | 1 + renovate.json | 5 ----- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .github/renovate.json delete mode 100644 renovate.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..b5aba8d --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "config:base" + ], + "labels": [ + "changelog: skip", + "dependencies" + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b09254b..ee0845c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,7 @@ repos: rev: v4.2.0 hooks: - id: check-merge-conflict + - id: check-json - id: check-yaml - repo: https://github.com/asottile/setup-cfg-fmt diff --git a/renovate.json b/renovate.json deleted file mode 100644 index f45d8f1..0000000 --- a/renovate.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": [ - "config:base" - ] -} From 4fc4a6ad8992a39df4e0e42a24a3d5279c2471cc Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 2 May 2022 18:10:25 +0300 Subject: [PATCH 699/768] Allow combining major bumps for GHA --- .github/renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/renovate.json b/.github/renovate.json index b5aba8d..92fd789 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -5,5 +5,12 @@ "labels": [ "changelog: skip", "dependencies" + ], + "packageRules": [ + { + "groupName": "github-actions", + "matchManagers": ["github-actions"], + "separateMajorMinor": "false" + } ] } From 75e2dd5f2e709ca91cbcbf734b376a7e7e7989ee Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 2 May 2022 15:20:10 +0000 Subject: [PATCH 700/768] chore(deps): update github-actions to v3 --- .github/workflows/deploy.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5002205..b0f5815 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index c22c0d0..95156ef 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -12,7 +12,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: micnncim/action-label-syncer@v1 with: prune: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b095963..649ca66 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-python@v3 - uses: pre-commit/action@v2.0.3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a63739..bdc8aa1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: os: [ubuntu-latest] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 @@ -40,7 +40,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 3823d77a35365331672721b508e3b58abc90520d Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 5 Jun 2022 20:27:34 +0000 Subject: [PATCH 701/768] chore(deps): update pre-commit/action action to v3 --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 649ca66..bd2ec4f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,4 +9,4 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v3 - - uses: pre-commit/action@v2.0.3 + - uses: pre-commit/action@v3.0.0 From 139e77707d07540fbd5f2a8f977edfba812623d5 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 8 Jun 2022 16:08:22 +0000 Subject: [PATCH 702/768] chore(deps): update actions/setup-python action to v4 --- .github/workflows/deploy.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b0f5815..f33fa85 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: "3.10" cache: pip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bd2ec4f..3ded542 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,5 +8,5 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 + - uses: actions/setup-python@v4 - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bdc8aa1..bf5d9ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: pip From 1e9d7d8c9434086e8a04443da45170906a71cfea Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 8 Jun 2022 19:55:48 +0300 Subject: [PATCH 703/768] A Python version is required for v4 --- .github/workflows/lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3ded542..c78a405 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,4 +9,6 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: "3.x" - uses: pre-commit/action@v3.0.0 From 7df369dfff631d672ed38baf03b468128096fe83 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jun 2022 16:53:11 +0000 Subject: [PATCH 704/768] chore(deps): update mheap/github-action-required-labels action to v2 --- .github/workflows/require-pr-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index a2c74d5..1079f3f 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v1 + - uses: mheap/github-action-required-labels@v2 with: mode: minimum count: 1 From aeba21dedb7a5465d2fac9f81cb099c21f81d7eb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Jul 2022 17:40:19 +0000 Subject: [PATCH 705/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.32.0 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.0...v2.34.0) - [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0) - [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee0845c..2b9ebc7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.0 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black args: [--target-version=py37] @@ -35,7 +35,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-merge-conflict - id: check-json From 8a967b52f4c1c7b1d031aff642ed93b5939f072a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 25 Jul 2022 21:21:03 +0300 Subject: [PATCH 706/768] Replace deprecated pypa/gh-action-pypi-publish@master with @release/v1 Committed via https://github.com/asottile/all-repos --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f33fa85..c2af694 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -39,13 +39,13 @@ jobs: - name: Publish package to PyPI if: github.event.action == 'published' - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.pypi_password }} - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.test_pypi_password }} From 7f1de76f6e1e8832381d87950951cb8a4579ec0e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 26 Sep 2022 11:30:50 +0300 Subject: [PATCH 707/768] Fix test_track_get_duration --- tests/test_track.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index 70d2044..dae2c9c 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """ Integration (not unit) tests for pylast.py """ @@ -110,7 +109,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): def test_track_get_duration(self) -> None: # Arrange - track = pylast.Track("Radiohead", "Creep", self.network) + track = pylast.Track("Daft Punk", "Something About Us", self.network) # Act duration = track.get_duration() From 98943d606e179772f1254ec56e5ef5c552ceef30 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 26 Sep 2022 11:04:45 +0300 Subject: [PATCH 708/768] Migrate from setup.* to pyproject.toml --- .flake8 | 2 + .github/workflows/deploy.yml | 5 +-- .github/workflows/test.yml | 4 +- .pre-commit-config.yaml | 25 +++++++----- pyproject.toml | 73 ++++++++++++++++++++++++++++++++++++ setup.cfg | 58 ---------------------------- setup.py | 12 ------ tox.ini | 1 + 8 files changed, 96 insertions(+), 84 deletions(-) create mode 100644 .flake8 create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..f4546ad --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max_line_length = 88 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2af694..0c72ce1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,9 +22,9 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.x" cache: pip - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install dependencies run: | @@ -33,7 +33,6 @@ jobs: - name: Build package run: | - python setup.py --version python -m build twine check --strict dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf5d9ba..e0d36a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: pip - cache-dependency-path: setup.cfg + cache-dependency-path: pyproject.toml - name: Install dependencies run: | @@ -48,7 +48,7 @@ jobs: success: needs: test runs-on: ubuntu-latest - name: test successful + name: Test successful steps: - name: Success run: echo Test successful diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b9ebc7..d076c82 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v2.38.2 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.8.0 hooks: - id: black args: [--target-version=py37] @@ -16,7 +16,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py37] - additional_dependencies: [black==21.12b0] + additional_dependencies: [black==22.8.0] - repo: https://github.com/PyCQA/isort rev: 5.10.1 @@ -24,7 +24,7 @@ repos: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -37,15 +37,22 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - - id: check-merge-conflict - id: check-json + - id: check-merge-conflict + - id: check-toml - id: check-yaml + - id: end-of-file-fixer + - id: requirements-txt-fixer - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.1 + - repo: https://github.com/tox-dev/pyproject-fmt + rev: 0.3.5 hooks: - - id: setup-cfg-fmt - args: [--max-py-version=3.11] + - id: pyproject-fmt + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.10.1 + hooks: + - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt rev: 0.5.2 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3e9af83 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = [ + "setuptools>=61.2", + "setuptools-scm", +] + +[project] +name = "pylast" +description = "A Python interface to Last.fm and Libre.fm" +readme = "README.md" +keywords = [ + "Last.fm", + "music", + "scrobble", + "scrobbling", +] +license = {text = "Apache-2.0"} +maintainers = [{name = "Hugo van Kemenade"}] +authors = [{name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com"}] +requires-python = ">=3.7" +dependencies = [ + "httpx", + 'importlib-metadata; python_version < "3.8"', +] +dynamic = [ + "version", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Internet", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Software Development :: Libraries :: Python Modules", +] +[project.optional-dependencies] +tests = [ + "flaky", + "pytest", + "pytest-cov", + "pytest-random-order", + "pyyaml", +] + +[project.urls] +Changelog = "https://github.com/pylast/pylast/releases" +Homepage = "https://github.com/pylast/pylast" +Source = "https://github.com/pylast/pylast" + + +[tool.isort] +profile = "black" + +[tool.setuptools] +package-dir = {"" = "src"} +license-files = ["LICENSE.txt"] +include-package-data = false + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.setuptools_scm] +local_scheme = "no-local-version" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5fa2a3d..0000000 --- a/setup.cfg +++ /dev/null @@ -1,58 +0,0 @@ -[metadata] -name = pylast -description = A Python interface to Last.fm and Libre.fm -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/pylast/pylast -author = Amr Hassan and Contributors -author_email = amr.hassan@gmail.com -maintainer = Hugo van Kemenade -license = Apache-2.0 -license_file = LICENSE.txt -classifiers = - Development Status :: 5 - Production/Stable - License :: OSI Approved :: Apache Software License - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: Implementation :: CPython - Programming Language :: Python :: Implementation :: PyPy - Topic :: Internet - Topic :: Multimedia :: Sound/Audio - Topic :: Software Development :: Libraries :: Python Modules -keywords = - Last.fm - music - scrobble - scrobbling - -[options] -packages = find: -install_requires = - httpx - importlib-metadata;python_version < '3.8' -python_requires = >=3.7 -package_dir = =src -setup_requires = - setuptools-scm - -[options.packages.find] -where = src - -[options.extras_require] -tests = - flaky - pytest - pytest-cov - pytest-random-order - pyyaml - -[flake8] -max_line_length = 88 - -[tool:isort] -profile = black diff --git a/setup.py b/setup.py deleted file mode 100755 index 630d39e..0000000 --- a/setup.py +++ /dev/null @@ -1,12 +0,0 @@ -from setuptools import setup - - -def local_scheme(version: str) -> str: - """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2) - to be able to upload to Test PyPI""" - return "" - - -setup( - use_scm_version={"local_scheme": local_scheme}, -) diff --git a/tox.ini b/tox.ini index 0f07848..a06262e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = lint py{py3, 311, 310, 39, 38, 37} +isolated_build = true [testenv] passenv = From fc288040a82497bb80bb0e492f2f1f6571fa8b73 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 26 Sep 2022 14:01:16 +0300 Subject: [PATCH 709/768] Migrate from setuptools + setuptools_scm to hatchling + hatch-vcs --- pyproject.toml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e9af83..461b858 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [build-system] -build-backend = "setuptools.build_meta" +build-backend = "hatchling.build" requires = [ - "setuptools>=61.2", - "setuptools-scm", + "hatch-vcs", + "hatchling", ] [project] @@ -57,17 +57,11 @@ Homepage = "https://github.com/pylast/pylast" Source = "https://github.com/pylast/pylast" +[tool.hatch] +version.source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + [tool.isort] profile = "black" - -[tool.setuptools] -package-dir = {"" = "src"} -license-files = ["LICENSE.txt"] -include-package-data = false - -[tool.setuptools.packages.find] -where = ["src"] -namespaces = false - -[tool.setuptools_scm] -local_scheme = "no-local-version" From dbbbcfec44b39635b6fd9cd670ea4ba908737909 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 26 Sep 2022 14:20:35 +0300 Subject: [PATCH 710/768] pyLast 5.1+ supports Python 3.7-3.11 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a2a667..baa3cb3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast) [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) [![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/main/graph/badge.svg)](https://codecov.io/gh/pylast/pylast) -[![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) [![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088) A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites @@ -35,6 +35,7 @@ Or from requirements.txt: Note: +* pyLast 5.1+ supports Python 3.7-3.11. * pyLast 5.0+ supports Python 3.7-3.10. * pyLast 4.3+ supports Python 3.6-3.10. * pyLast 4.0 - 4.2 supports Python 3.6-3.9. From 41e0dd604ef61b06c79ff6cc88972bac2430d4bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Oct 2022 17:43:21 +0000 Subject: [PATCH 711/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v2.34.0 → v2.38.2](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.38.2) - [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0) - [github.com/PyCQA/flake8: 4.0.1 → 5.0.4](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.4) - [github.com/asottile/setup-cfg-fmt: v1.20.1 → v2.0.0](https://github.com/asottile/setup-cfg-fmt/compare/v1.20.1...v2.0.0) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d076c82..578769b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.38.2 + rev: v3.1.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 22.10.0 hooks: - id: black args: [--target-version=py37] @@ -16,7 +16,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py37] - additional_dependencies: [black==22.8.0] + additional_dependencies: [black==22.10.0] - repo: https://github.com/PyCQA/isort rev: 5.10.1 From 7f3518fc1a760cf6efa8d4a56986c92f769d23fb Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 25 Oct 2022 18:32:50 +0300 Subject: [PATCH 712/768] Test on Python 3.11 final --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0d36a9..d566e4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11-dev"] + python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest] steps: From d03e25fc6c8a49c0fa8306ec2dcd19f938fc9837 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Wed, 9 Nov 2022 13:44:07 +0200 Subject: [PATCH 713/768] Test Python 3.12-dev Committed via https://github.com/asottile/all-repos --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d566e4c..280b767 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] os: [ubuntu-latest] steps: From 8169fad09db34d0b52e32365241aa1b8143f3417 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 30 Dec 2022 21:56:31 +0000 Subject: [PATCH 714/768] chore(deps): update mheap/github-action-required-labels action to v3 --- .github/workflows/require-pr-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index 1079f3f..d243fa6 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v2 + - uses: mheap/github-action-required-labels@v3 with: mode: minimum count: 1 From 7861fd55bdd412b30096641787598b0e22918625 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Jan 2023 18:09:58 +0000 Subject: [PATCH 715/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.1.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v3.1.0...v3.3.1) - [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0) - [github.com/PyCQA/isort: 5.10.1 → 5.11.4](https://github.com/PyCQA/isort/compare/5.10.1...5.11.4) - [github.com/PyCQA/flake8: 5.0.4 → 6.0.0](https://github.com/PyCQA/flake8/compare/5.0.4...6.0.0) - [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0) - [github.com/tox-dev/pyproject-fmt: 0.3.5 → 0.4.1](https://github.com/tox-dev/pyproject-fmt/compare/0.3.5...0.4.1) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 578769b..3c7c47d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.1.0 + rev: v3.3.1 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black args: [--target-version=py37] @@ -19,12 +19,12 @@ repos: additional_dependencies: [black==22.10.0] - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.11.4 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 6.0.0 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -35,7 +35,7 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-json - id: check-merge-conflict @@ -45,7 +45,7 @@ repos: - id: requirements-txt-fixer - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.3.5 + rev: 0.4.1 hooks: - id: pyproject-fmt From a37ac22e6c74c41a6b88e26ef55b0654432debb5 Mon Sep 17 00:00:00 2001 From: ndm13 Date: Mon, 2 Jan 2023 15:09:16 -0500 Subject: [PATCH 716/768] Add code from pylast #407 to readme Describe authentication with OAuth token --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index baa3cb3..8676805 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,32 @@ network = pylast.LastFMNetwork( password_hash=password_hash, ) +# You can also authenticate with a session key +SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key") +network = pylast.LastFMNetwork(API_KEY, API_SECRET) +if not os.path.exists(SESSION_KEY_FILE): + skg = pylast.SessionKeyGenerator(network) + url = skg.get_web_auth_url() + + print(f"Please authorize this script to access your account: {url}\n") + import time + import webbrowser + + webbrowser.open(url) + + while True: + try: + session_key = skg.get_web_auth_session_key(url) + with open(SESSION_KEY_FILE, "w") as f: + f.write(session_key) + break + except pylast.WSError: + time.sleep(1) +else: + session_key = open(SESSION_KEY_FILE).read() + +network.session_key = session_key + # Now you can use that object everywhere track = network.get_track("Iron Maiden", "The Nomad") track.love() From 8647cbdd4844dd80ab9762fc8e18c0cf225b2cc9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 5 Jan 2023 18:36:38 +0200 Subject: [PATCH 717/768] Make alternative clearer via own code blocks --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8676805..4c624ba 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,12 @@ network = pylast.LastFMNetwork( username=username, password_hash=password_hash, ) +``` -# You can also authenticate with a session key +Alternatively, instead of creating `network` with a username and password, +you can authenticate with a session key: + +```python SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key") network = pylast.LastFMNetwork(API_KEY, API_SECRET) if not os.path.exists(SESSION_KEY_FILE): @@ -110,7 +114,11 @@ else: session_key = open(SESSION_KEY_FILE).read() network.session_key = session_key +``` +And away we go: + +```python # Now you can use that object everywhere track = network.get_track("Iron Maiden", "The Nomad") track.love() @@ -120,6 +128,7 @@ track.add_tags(("awesome", "favorite")) # to get more help about anything and see examples of how it works ``` + More examples in hugovk/lastfm-tools and [tests/](https://github.com/pylast/pylast/tree/main/tests). From 28403386a8438722226ece7081aac5944575ffc1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 5 Jan 2023 18:41:16 +0200 Subject: [PATCH 718/768] Bump Black --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c7c47d..405505c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py37] - additional_dependencies: [black==22.10.0] + additional_dependencies: [black==22.12.0] - repo: https://github.com/PyCQA/isort rev: 5.11.4 From e63ecc7bea1bd4412d31736bf4266f9ce52afbda Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 5 Jan 2023 18:42:43 +0200 Subject: [PATCH 719/768] Autolabel pre-commit PRs with 'changelog: skip' --- .github/release-drafter.yml | 5 +++++ .github/workflows/release-drafter.yml | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 67eccf9..ba26220 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -22,6 +22,11 @@ categories: exclude-labels: - "changelog: skip" +autolabeler: + - label: "changelog: skip" + branch: + - "/pre-commit-ci-update-config/" + template: | $CHANGES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index cb11924..3f24b79 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -5,11 +5,27 @@ on: # branches to consider in the event; optional, defaults to all branches: - main + # pull_request event is required only for autolabeler + pull_request: + # Only following types are handled by the action, but one can default to all as well + types: [opened, reopened, synchronize] + # pull_request_target event is required for autolabeler to support PRs from forks + # pull_request_target: + # types: [opened, reopened, synchronize] workflow_dispatch: +permissions: + contents: read + jobs: update_release_draft: if: github.repository_owner == 'pylast' + permissions: + # write permission is required to create a GitHub Release + contents: write + # write permission is required for autolabeler + # otherwise, read permission is required at least + pull-requests: write runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" From f5ea06c6c9488b0f6ffeee52374f197f7a5d7224 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 6 Jan 2023 09:49:52 +0200 Subject: [PATCH 720/768] Include "import pylast" in both blocks --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4c624ba..0739ee7 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Alternatively, instead of creating `network` with a username and password, you can authenticate with a session key: ```python +import pylast + SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key") network = pylast.LastFMNetwork(API_KEY, API_SECRET) if not os.path.exists(SESSION_KEY_FILE): From dab0a5b6614b5cdc9b9fd73fd47e81795377c671 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 29 Jan 2023 13:27:16 +0200 Subject: [PATCH 721/768] Bump isort to fix Poetry Re: https://github.com/PyCQA/isort/pull/2078 Committed via https://github.com/asottile/all-repos --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 405505c..86f2f3e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: additional_dependencies: [black==22.12.0] - repo: https://github.com/PyCQA/isort - rev: 5.11.4 + rev: 5.12.0 hooks: - id: isort From 15f0ccfd58117bee8e04f41eabd4b53fa9e7ba4e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 19 Mar 2023 15:53:12 +0200 Subject: [PATCH 722/768] Replace deprecated repository_url with repository-url Committed via https://github.com/asottile/all-repos --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c72ce1..95ae3da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,4 +48,4 @@ jobs: with: user: __token__ password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ + repository-url: https://test.pypi.org/legacy/ From 111334328e584875a71a789074a0fe5c2f54cf43 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 2 Apr 2023 09:06:18 +0000 Subject: [PATCH 723/768] chore(deps): update mheap/github-action-required-labels action to v4 --- .github/workflows/require-pr-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index d243fa6..3b997b2 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v3 + - uses: mheap/github-action-required-labels@v4 with: mode: minimum count: 1 From 6a7a23cd9a508a7e1ca7c48fc4f5ffa7da39930b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 19:10:46 +0000 Subject: [PATCH 724/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.12.0 → 23.3.0](https://github.com/psf/black/compare/22.12.0...23.3.0) - [github.com/asottile/blacken-docs: v1.12.1 → 1.13.0](https://github.com/asottile/blacken-docs/compare/v1.12.1...1.13.0) - [github.com/pre-commit/pygrep-hooks: v1.9.0 → v1.10.0](https://github.com/pre-commit/pygrep-hooks/compare/v1.9.0...v1.10.0) - [github.com/tox-dev/pyproject-fmt: 0.4.1 → 0.9.2](https://github.com/tox-dev/pyproject-fmt/compare/0.4.1...0.9.2) - [github.com/abravalheri/validate-pyproject: v0.10.1 → v0.12.2](https://github.com/abravalheri/validate-pyproject/compare/v0.10.1...v0.12.2) - [github.com/tox-dev/tox-ini-fmt: 0.5.2 → 1.0.0](https://github.com/tox-dev/tox-ini-fmt/compare/0.5.2...1.0.0) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86f2f3e..41b6873 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,13 +6,13 @@ repos: args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.3.0 hooks: - id: black args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 + rev: 1.13.0 hooks: - id: blacken-docs args: [--target-version=py37] @@ -30,7 +30,7 @@ repos: additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.9.0 + rev: v1.10.0 hooks: - id: python-check-blanket-noqa @@ -45,17 +45,17 @@ repos: - id: requirements-txt-fixer - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.4.1 + rev: 0.9.2 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.10.1 + rev: v0.12.2 hooks: - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 0.5.2 + rev: 1.0.0 hooks: - id: tox-ini-fmt From 879591e1cc90c997d9d3c940686e255d91dae5bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 19:10:56 +0000 Subject: [PATCH 725/768] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 16 +++++++--------- src/pylast/__init__.py | 4 ---- tests/test_pylast.py | 1 - tox.ini | 8 ++++---- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 461b858..141679e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,13 +19,6 @@ license = {text = "Apache-2.0"} maintainers = [{name = "Hugo van Kemenade"}] authors = [{name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com"}] requires-python = ">=3.7" -dependencies = [ - "httpx", - 'importlib-metadata; python_version < "3.8"', -] -dynamic = [ - "version", -] classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", @@ -42,6 +35,13 @@ classifiers = [ "Topic :: Multimedia :: Sound/Audio", "Topic :: Software Development :: Libraries :: Python Modules", ] +dynamic = [ + "version", +] +dependencies = [ + "httpx", + 'importlib-metadata; python_version < "3.8"', +] [project.optional-dependencies] tests = [ "flaky", @@ -50,13 +50,11 @@ tests = [ "pytest-random-order", "pyyaml", ] - [project.urls] Changelog = "https://github.com/pylast/pylast/releases" Homepage = "https://github.com/pylast/pylast" Source = "https://github.com/pylast/pylast" - [tool.hatch] version.source = "vcs" diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index e152c31..c6cd8be 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -546,7 +546,6 @@ class _Network: context=None, mbid=None, ): - """Used to add a track-play to a user's profile. Parameters: @@ -600,7 +599,6 @@ class _Network: params = {} for i in range(len(tracks_to_scrobble)): - params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"] params[f"track[{i}]"] = tracks_to_scrobble[i]["title"] @@ -621,7 +619,6 @@ class _Network: } for arg in additional_args: - if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]: if arg in args_map_to: maps_to = args_map_to[arg] @@ -736,7 +733,6 @@ class LibreFMNetwork(_Network): username: str = "", password_hash: str = "", ) -> None: - super().__init__( name="Libre.fm", homepage="https://libre.fm", diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 7125413..cdaf50e 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -47,7 +47,6 @@ def _no_xfail_rerun_filter(err, name, test, plugin) -> bool: @flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter) class TestPyLastWithLastFm(PyLastTestCase): - secrets = None def unix_timestamp(self): diff --git a/tox.ini b/tox.ini index a06262e..de0b9da 100644 --- a/tox.ini +++ b/tox.ini @@ -5,23 +5,23 @@ envlist = isolated_build = true [testenv] +extras = + tests passenv = FORCE_COLOR PYLAST_API_KEY PYLAST_API_SECRET PYLAST_PASSWORD_HASH PYLAST_USERNAME -extras = - tests commands = pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --cov-report xml --random-order {posargs} [testenv:lint] -passenv = - PRE_COMMIT_COLOR skip_install = true deps = pre-commit +passenv = + PRE_COMMIT_COLOR commands = pre-commit run --all-files --show-diff-on-failure From cdb88b9bbbce884865755ead8112155fb230774b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 18 Apr 2023 05:28:30 -0600 Subject: [PATCH 726/768] Update pre-commit --- .pre-commit-config.yaml | 4 ++-- tox.ini | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41b6873..b4d8bf3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py37] - additional_dependencies: [black==22.12.0] + additional_dependencies: [black==23.3.0] - repo: https://github.com/PyCQA/isort rev: 5.12.0 @@ -55,7 +55,7 @@ repos: - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.0.0 + rev: 1.3.0 hooks: - id: tox-ini-fmt diff --git a/tox.ini b/tox.ini index de0b9da..7c01999 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] -envlist = +requires = + tox>=4.2 +env_list = lint py{py3, 311, 310, 39, 38, 37} -isolated_build = true [testenv] extras = tests -passenv = +pass_env = FORCE_COLOR PYLAST_API_KEY PYLAST_API_SECRET @@ -20,7 +21,7 @@ commands = skip_install = true deps = pre-commit -passenv = +pass_env = PRE_COMMIT_COLOR commands = pre-commit run --all-files --show-diff-on-failure From 9f59dd770c887aeee6da673422b788c53c7d5a4e Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 18 Apr 2023 06:04:33 -0600 Subject: [PATCH 727/768] Add support for Python 3.12 --- README.md | 7 ++++--- pyproject.toml | 1 + tox.ini | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0739ee7..ce20f6b 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,10 @@ Or from requirements.txt: Note: -* pyLast 5.1+ supports Python 3.7-3.11. -* pyLast 5.0+ supports Python 3.7-3.10. -* pyLast 4.3+ supports Python 3.6-3.10. +* pyLast 5.2+ supports Python 3.7-3.12. +* pyLast 5.1 supports Python 3.7-3.11. +* pyLast 5.0 supports Python 3.7-3.10. +* pyLast 4.3 - 4.5 supports Python 3.6-3.10. * pyLast 4.0 - 4.2 supports Python 3.6-3.9. * pyLast 3.2 - 3.3 supports Python 3.5-3.8. * pyLast 3.0 - 3.1 supports Python 3.5-3.7. diff --git a/pyproject.toml b/pyproject.toml index 141679e..fc658ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet", diff --git a/tox.ini b/tox.ini index 7c01999..641e732 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 311, 310, 39, 38, 37} + py{py3, 312, 311, 310, 39, 38, 37} [testenv] extras = From 56fc2973717a4694ed8f5901380255f1a304dba2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 18 Apr 2023 06:08:52 -0600 Subject: [PATCH 728/768] Publish to PyPI with a Trusted Publisher --- .github/workflows/deploy.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95ae3da..3846d7e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,6 +14,10 @@ jobs: if: github.repository_owner == 'pylast' runs-on: ubuntu-latest + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: - uses: actions/checkout@v3 with: @@ -39,13 +43,8 @@ jobs: - name: Publish package to PyPI if: github.event.action == 'published' uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.pypi_password }} - name: Publish package to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.test_pypi_password }} repository-url: https://test.pypi.org/legacy/ From b05b8454f5303050a73724a8207d4c6b9597a5d3 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 18 Apr 2023 06:09:25 -0600 Subject: [PATCH 729/768] Update pre-commit --- .pre-commit-config.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b4d8bf3..5529b8d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,6 @@ repos: rev: 23.3.0 hooks: - id: black - args: [--target-version=py37] - repo: https://github.com/asottile/blacken-docs rev: 1.13.0 @@ -33,12 +32,14 @@ repos: rev: v1.10.0 hooks: - id: python-check-blanket-noqa + - id: python-no-log-warn - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - - id: check-json + - id: check-case-conflict - id: check-merge-conflict + - id: check-json - id: check-toml - id: check-yaml - id: end-of-file-fixer From dc4bd8474cfe45945120f012a6174561f0fdf9c7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 18 Apr 2023 06:19:54 -0600 Subject: [PATCH 730/768] Test newest PyPy --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 280b767..e3f067c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] + python-version: ["pypy3.9", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] os: [ubuntu-latest] steps: From 0f59831dc21b51bcf5b4468bc822a84af3968dd5 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 3 Jun 2023 17:41:35 +0300 Subject: [PATCH 731/768] Drop support for EOL Python 3.7 --- .github/workflows/test.yml | 3 ++- .pre-commit-config.yaml | 10 +++++----- README.md | 2 +- pyproject.toml | 32 +++++++++++++++----------------- src/pylast/__init__.py | 10 ++-------- tox.ini | 2 +- 6 files changed, 26 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3f067c..3fda04c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.9", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] + python-version: ["pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] steps: @@ -21,6 +21,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5529b8d..0de09d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.4.0 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py38-plus] - repo: https://github.com/psf/black rev: 23.3.0 @@ -14,7 +14,7 @@ repos: rev: 1.13.0 hooks: - id: blacken-docs - args: [--target-version=py37] + args: [--target-version=py38] additional_dependencies: [black==23.3.0] - repo: https://github.com/PyCQA/isort @@ -46,12 +46,12 @@ repos: - id: requirements-txt-fixer - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.9.2 + rev: 0.10.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.2 + rev: v0.13 hooks: - id: validate-pyproject diff --git a/README.md b/README.md index ce20f6b..3d9e882 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Or from requirements.txt: Note: -* pyLast 5.2+ supports Python 3.7-3.12. +* pyLast 5.2+ supports Python 3.8-3.12. * pyLast 5.1 supports Python 3.7-3.11. * pyLast 5.0 supports Python 3.7-3.10. * pyLast 4.3 - 4.5 supports Python 3.6-3.10. diff --git a/pyproject.toml b/pyproject.toml index fc658ff..d1224dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,30 +18,28 @@ keywords = [ license = {text = "Apache-2.0"} maintainers = [{name = "Hugo van Kemenade"}] authors = [{name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com"}] -requires-python = ">=3.7" +requires-python = ">=3.8" classifiers = [ - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Internet", - "Topic :: Multimedia :: Sound/Audio", - "Topic :: Software Development :: Libraries :: Python Modules", + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Internet", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = [ "version", ] dependencies = [ "httpx", - 'importlib-metadata; python_version < "3.8"', ] [project.optional-dependencies] tests = [ diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c6cd8be..60f9b5f 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -23,6 +23,7 @@ from __future__ import annotations import collections import hashlib import html.entities +import importlib.metadata import logging import os import re @@ -36,18 +37,11 @@ from xml.dom import Node, minidom import httpx -try: - # Python 3.8+ - import importlib.metadata as importlib_metadata -except ImportError: - # Python 3.7 and lower - import importlib_metadata # type: ignore - __author__ = "Amr Hassan, hugovk, Mice Pápai" __copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai" __license__ = "apache2" __email__ = "amr.hassan@gmail.com" -__version__ = importlib_metadata.version(__name__) +__version__ = importlib.metadata.version(__name__) # 1 : This error does not exist diff --git a/tox.ini b/tox.ini index 641e732..b0d3588 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 312, 311, 310, 39, 38, 37} + py{py3, 312, 311, 310, 39, 38} [testenv] extras = From 1c669d8bb01c4274ced602816cf87337731f0d25 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 3 Jun 2023 17:55:00 +0300 Subject: [PATCH 732/768] Fix test: now returns a png --- tests/test_album.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_album.py b/tests/test_album.py index ae2c1a0..a56faf1 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -94,8 +94,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm): image = album.get_cover_image() # Assert - self.assert_startswith(image, "https://") - self.assert_endswith(image, ".gif") + assert image.startswith("https://") + assert image.endswith(".gif") or image.endswith(".png") def test_mbid(self) -> None: # Arrange From 7da76f49bdf4570906459d1ec135460b5863c364 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 3 Jun 2023 18:03:05 +0300 Subject: [PATCH 733/768] Refactor: replace redundant helper methods, no need with pytest --- tests/test_library.py | 4 ++-- tests/test_librefm.py | 6 +++--- tests/test_network.py | 24 ++++++++++++------------ tests/test_pylast.py | 10 +--------- tests/test_user.py | 8 ++++---- 5 files changed, 22 insertions(+), 30 deletions(-) diff --git a/tests/test_library.py b/tests/test_library.py index e37b771..cc35233 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -16,7 +16,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm): representation = repr(library) # Assert - self.assert_startswith(representation, "pylast.Library(") + assert representation.startswith("pylast.Library(") def test_str(self) -> None: # Arrange @@ -26,7 +26,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm): string = str(library) # Assert - self.assert_endswith(string, "'s Library") + assert string.endswith("'s Library") def test_library_is_hashable(self) -> None: # Arrange diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 0647976..01c43d9 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -6,11 +6,11 @@ from flaky import flaky import pylast -from .test_pylast import PyLastTestCase, load_secrets +from .test_pylast import load_secrets @flaky(max_runs=3, min_passes=1) -class TestPyLastWithLibreFm(PyLastTestCase): +class TestPyLastWithLibreFm: """Own class for Libre.fm because we don't need the Last.fm setUp""" def test_libre_fm(self) -> None: @@ -38,4 +38,4 @@ class TestPyLastWithLibreFm(PyLastTestCase): representation = repr(network) # Assert - self.assert_startswith(representation, "pylast.LibreFMNetwork(") + assert representation.startswith("pylast.LibreFMNetwork(") diff --git a/tests/test_network.py b/tests/test_network.py index d10cc66..51f0b18 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -330,12 +330,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert len(images) == 4 - self.assert_startswith(images[pylast.SIZE_SMALL], "https://") - self.assert_endswith(images[pylast.SIZE_SMALL], ".png") + assert images[pylast.SIZE_SMALL].startswith("https://") + assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] - self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") - self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") + assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") + assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_artist_search(self) -> None: @@ -362,12 +362,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert len(images) == 5 - self.assert_startswith(images[pylast.SIZE_SMALL], "https://") - self.assert_endswith(images[pylast.SIZE_SMALL], ".png") + assert images[pylast.SIZE_SMALL].startswith("https://") + assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] - self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") - self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") + assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") + assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_track_search(self) -> None: @@ -396,12 +396,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm): # Assert assert len(images) == 4 - self.assert_startswith(images[pylast.SIZE_SMALL], "https://") - self.assert_endswith(images[pylast.SIZE_SMALL], ".png") + assert images[pylast.SIZE_SMALL].startswith("https://") + assert images[pylast.SIZE_SMALL].endswith(".png") assert "/34s/" in images[pylast.SIZE_SMALL] - self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://") - self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png") + assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://") + assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png") assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE] def test_search_get_total_result_count(self) -> None: diff --git a/tests/test_pylast.py b/tests/test_pylast.py index cdaf50e..2c3c7c7 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -32,21 +32,13 @@ def load_secrets(): # pragma: no cover return doc -class PyLastTestCase: - def assert_startswith(self, s, prefix, start=None, end=None) -> None: - assert s.startswith(prefix, start, end) - - def assert_endswith(self, s, suffix, start=None, end=None) -> None: - assert s.endswith(suffix, start, end) - - def _no_xfail_rerun_filter(err, name, test, plugin) -> bool: for _ in test.iter_markers(name="xfail"): return False @flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter) -class TestPyLastWithLastFm(PyLastTestCase): +class TestPyLastWithLastFm: secrets = None def unix_timestamp(self): diff --git a/tests/test_user.py b/tests/test_user.py index 6e0f3ba..12308eb 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -24,7 +24,7 @@ class TestPyLastUser(TestPyLastWithLastFm): representation = repr(user) # Assert - self.assert_startswith(representation, "pylast.User('RJ',") + assert representation.startswith("pylast.User('RJ',") def test_str(self) -> None: # Arrange @@ -345,7 +345,7 @@ class TestPyLastUser(TestPyLastWithLastFm): url = user.get_image() # Assert - self.assert_startswith(url, "https://") + assert url.startswith("https://") def test_user_get_library(self) -> None: # Arrange @@ -428,8 +428,8 @@ class TestPyLastUser(TestPyLastWithLastFm): image = user.get_image() # Assert - self.assert_startswith(image, "https://") - self.assert_endswith(image, ".png") + assert image.startswith("https://") + assert image.endswith(".png") def test_get_url(self) -> None: # Arrange From 47eda3ea703de2bce5c701fe8492d1957c60a5b9 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sat, 3 Jun 2023 18:10:01 +0300 Subject: [PATCH 734/768] Refactor: make helper methods static --- tests/test_pylast.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 2c3c7c7..4beeed7 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -41,7 +41,8 @@ def _no_xfail_rerun_filter(err, name, test, plugin) -> bool: class TestPyLastWithLastFm: secrets = None - def unix_timestamp(self): + @staticmethod + def unix_timestamp() -> int: return int(time.time()) @classmethod @@ -62,7 +63,8 @@ class TestPyLastWithLastFm: password_hash=password_hash, ) - def helper_is_thing_hashable(self, thing) -> None: + @staticmethod + def helper_is_thing_hashable(thing) -> None: # Arrange things = set() @@ -73,7 +75,8 @@ class TestPyLastWithLastFm: assert thing is not None assert len(things) == 1 - def helper_validate_results(self, a, b, c) -> None: + @staticmethod + def helper_validate_results(a, b, c) -> None: # Assert assert a is not None assert b is not None @@ -97,27 +100,31 @@ class TestPyLastWithLastFm: # Assert self.helper_validate_results(result1, result2, result3) - def helper_at_least_one_thing_in_top_list(self, things, expected_type) -> None: + @staticmethod + def helper_at_least_one_thing_in_top_list(things, expected_type) -> None: # Assert assert len(things) > 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) - def helper_only_one_thing_in_top_list(self, things, expected_type) -> None: + @staticmethod + def helper_only_one_thing_in_top_list(things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], pylast.TopItem) assert isinstance(things[0].item, expected_type) - def helper_only_one_thing_in_list(self, things, expected_type) -> None: + @staticmethod + def helper_only_one_thing_in_list(things, expected_type) -> None: # Assert assert len(things) == 1 assert isinstance(things, list) assert isinstance(things[0], expected_type) - def helper_two_different_things_in_top_list(self, things, expected_type) -> None: + @staticmethod + def helper_two_different_things_in_top_list(things, expected_type) -> None: # Assert assert len(things) == 2 thing1 = things[0] From 02da99f4b0347042d799e5c5becc1a974c667202 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 6 Jun 2023 19:20:56 +0300 Subject: [PATCH 735/768] Use hynek/build-and-inspect-python-package --- .flake8 | 2 +- .github/workflows/deploy.yml | 80 ++++++++++++++++---------- .github/workflows/lint.yml | 3 + .github/workflows/require-pr-label.yml | 3 + 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/.flake8 b/.flake8 index f4546ad..2bcd70e 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,2 @@ [flake8] -max_line_length = 88 +max-line-length = 88 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3846d7e..6594ecf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,49 +2,71 @@ name: Deploy on: push: - branches: - - main + branches: [main] + tags: ["*"] + pull_request: + branches: [main] release: types: - published workflow_dispatch: -jobs: - deploy: - if: github.repository_owner == 'pylast' - runs-on: ubuntu-latest +permissions: + contents: read - permissions: - # IMPORTANT: this permission is mandatory for trusted publishing - id-token: write +jobs: + # Always build & lint package. + build-package: + name: Build & verify package + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: hynek/build-and-inspect-python-package@v1 + + # Upload to Test PyPI on every commit on main. + release-test-pypi: + name: Publish in-dev package to test.pypi.org + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + needs: build-package + + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v3 with: - python-version: "3.x" - cache: pip - cache-dependency-path: pyproject.toml + name: Packages + path: dist - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -U build twine wheel - - - name: Build package - run: | - python -m build - twine check --strict dist/* - - - name: Publish package to PyPI - if: github.event.action == 'published' - uses: pypa/gh-action-pypi-publish@release/v1 - - - name: Publish package to TestPyPI + - name: Upload package to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ + + # Upload to real PyPI on GitHub Releases. + release-pypi: + name: Publish released package to pypi.org + if: github.event.action == 'published' + runs-on: ubuntu-latest + needs: build-package + + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + + steps: + - name: Download packages built by build-and-inspect-python-package + uses: actions/download-artifact@v3 + with: + name: Packages + path: dist + + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c78a405..477218a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ name: Lint on: [push, pull_request, workflow_dispatch] +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index 3b997b2..2d97091 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -8,6 +8,9 @@ jobs: label: runs-on: ubuntu-latest + permissions: + issues: write + steps: - uses: mheap/github-action-required-labels@v4 with: From f7a73aa62f0df79efab9ce583f2083d2f4f9333e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Jun 2023 17:29:12 +0000 Subject: [PATCH 736/768] Update mheap/github-action-required-labels action to v5 --- .github/workflows/require-pr-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index 2d97091..85c3e3e 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - - uses: mheap/github-action-required-labels@v4 + - uses: mheap/github-action-required-labels@v5 with: mode: minimum count: 1 From c26c5f86aa4d575ddf5a74a17fdfdc397a30a5a4 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 8 Jun 2023 21:22:05 +0300 Subject: [PATCH 737/768] Update on the first day of the month --- .github/renovate.json | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 92fd789..2d2f276 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,16 +1,13 @@ { - "extends": [ - "config:base" - ], - "labels": [ - "changelog: skip", - "dependencies" - ], + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:base"], + "labels": ["changelog: skip", "dependencies"], "packageRules": [ { "groupName": "github-actions", "matchManagers": ["github-actions"], "separateMajorMinor": "false" } - ] + ], + "schedule": ["on the first day of the month"] } From 68c0197028dd2fe6b43c423f044026e1a732a4b7 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 18 Jun 2023 14:42:29 +0300 Subject: [PATCH 738/768] CI: Replace pypy3.9 with pypy3.10 Committed via https://github.com/asottile/all-repos --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3fda04c..ef202d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12"] os: [ubuntu-latest] steps: From e4b7af41f936e8fa2c9d63e62c50ed62c9f807a1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Mon, 24 Jul 2023 17:33:42 +0300 Subject: [PATCH 739/768] Remove default 'cache-dependency-path: pyproject.toml' Committed via https://github.com/asottile/all-repos --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef202d0..891e2cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip - cache-dependency-path: pyproject.toml + - name: Install dependencies run: | From 74392c4d718b5187004fb8eaf680ae81fadf6816 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 22 Aug 2023 14:22:24 +0300 Subject: [PATCH 740/768] [pre-commit.ci] pre-commit autoupdate (#434) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hugo van Kemenade --- .pre-commit-config.yaml | 17 ++++++++++------- COPYING | 6 +++--- pyproject.toml | 1 - src/pylast/__init__.py | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0de09d9..fe0a791 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black - repo: https://github.com/asottile/blacken-docs - rev: 1.13.0 + rev: 1.16.0 hooks: - id: blacken-docs args: [--target-version=py38] @@ -23,7 +23,7 @@ repos: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: [flake8-2020, flake8-implicit-str-concat] @@ -44,19 +44,22 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: requirements-txt-fixer + - id: trailing-whitespace + exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.10.0 + rev: 0.13.1 hooks: - id: pyproject-fmt + additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.13 + rev: v0.14 hooks: - id: validate-pyproject - repo: https://github.com/tox-dev/tox-ini-fmt - rev: 1.3.0 + rev: 1.3.1 hooks: - id: tox-ini-fmt diff --git a/COPYING b/COPYING index c4ff845..5b651ea 100644 --- a/COPYING +++ b/COPYING @@ -32,11 +32,11 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: -You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must give any other recipients of the Work or Derivative Works a copy of this License; and -You must cause any modified files to carry prominent notices stating that You changed the files; and +You must cause any modified files to carry prominent notices stating that You changed the files; and -You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. diff --git a/pyproject.toml b/pyproject.toml index d1224dd..52292bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ requires-python = ">=3.8" classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 60f9b5f..d901c64 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1505,7 +1505,7 @@ class _Opus(_Taggable): return f"{self.get_artist().get_name()} - {self.get_title()}" def __eq__(self, other): - if type(self) != type(other): + if type(self) is not type(other): return False a = self.get_title().lower() b = other.get_title().lower() From 47872dbb3288f2230c3d8d20cf18ac126dcdbd8e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Oct 2023 08:37:46 +0300 Subject: [PATCH 741/768] Update actions/checkout action to v4 (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://togithub.com/actions/checkout) | action | major | `v3` -> `v4` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v4`](https://togithub.com/actions/checkout/blob/HEAD/CHANGELOG.md#v400) [Compare Source](https://togithub.com/actions/checkout/compare/v3...v4) - [Support fetching without the --progress option](https://togithub.com/actions/checkout/pull/1067) - [Update to node20](https://togithub.com/actions/checkout/pull/1436)
--- ### Configuration 📅 **Schedule**: Branch creation - "on the first day of the month" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://developer.mend.io/github/pylast/pylast). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/deploy.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6594ecf..924a079 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 95156ef..9ca7454 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -12,7 +12,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: micnncim/action-label-syncer@v1 with: prune: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 477218a..4cf8d37 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.x" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 891e2cd..2e8f5f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: os: [ubuntu-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 From c0f9f4222a1fd82ae295bafb7528dc73ccc15ec1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:16:48 -0600 Subject: [PATCH 742/768] [pre-commit.ci] pre-commit autoupdate (#437) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe0a791..bb81967 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.13.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black @@ -48,7 +48,7 @@ repos: exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/tox-dev/pyproject-fmt - rev: 0.13.1 + rev: 1.2.0 hooks: - id: pyproject-fmt additional_dependencies: [tox] From a91bac007d46fbb86d9b27726c293a6d6fb44f56 Mon Sep 17 00:00:00 2001 From: Mia Bilka Date: Thu, 26 Oct 2023 18:02:01 -0700 Subject: [PATCH 743/768] Fixed incorrect docstrings --- src/pylast/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index d901c64..0de19d7 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1156,7 +1156,7 @@ class _BaseObject: def get_wiki_published_date(self): """ - Returns the summary of the wiki. + Returns the date on which the wiki was published. Only for Album/Track. """ return self.get_wiki("published") @@ -1170,7 +1170,7 @@ class _BaseObject: def get_wiki_content(self): """ - Returns the summary of the wiki. + Returns the content of the wiki. Only for Album/Track. """ return self.get_wiki("content") @@ -1543,7 +1543,7 @@ class _Opus(_Taggable): return self.info["image"][size] def get_title(self, properly_capitalized: bool = False): - """Returns the artist or track title.""" + """Returns the album or track title.""" if properly_capitalized: self.title = _extract( self._request(self.ws_prefix + ".getInfo", True), "name" From b4c8dc728237ff68956e376142bfd57a0d8ddae2 Mon Sep 17 00:00:00 2001 From: Eugene Simonov Date: Fri, 29 Dec 2023 21:00:10 +0200 Subject: [PATCH 744/768] Add type annotations to methods that take timestamp parameter (#442) Co-authored-by: Eugene Simonov Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hugo van Kemenade --- src/pylast/__init__.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 0de19d7..c94e714 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -529,25 +529,25 @@ class _Network: def scrobble( self, - artist, - title, - timestamp, - album=None, - album_artist=None, - track_number=None, - duration=None, - stream_id=None, - context=None, - mbid=None, + artist: str, + title: str, + timestamp: int, + album: str | None = None, + album_artist: str | None = None, + track_number: int | None = None, + duration: int | None = None, + stream_id: str | None = None, + context: str | None = None, + mbid: str | None = None, ): """Used to add a track-play to a user's profile. Parameters: artist (Required) : The artist name. title (Required) : The track name. - timestamp (Required) : The time the track started playing, in UNIX + timestamp (Required) : The time the track started playing, in Unix timestamp format (integer number of seconds since 00:00:00, - January 1st 1970 UTC). This must be in the UTC time zone. + January 1st 1970 UTC). album (Optional) : The album name. album_artist (Optional) : The album artist - if this differs from the track artist. @@ -2295,8 +2295,8 @@ class User(_Chartable): self, limit: int = 10, cacheable: bool = True, - time_from=None, - time_to=None, + time_from: int | None = None, + time_to: int | None = None, stream: bool = False, now_playing: bool = False, ): @@ -2307,13 +2307,11 @@ class User(_Chartable): Parameters: limit : If None, it will try to pull all the available data. from (Optional) : Beginning timestamp of a range - only display - scrobbles after this time, in UNIX timestamp format (integer - number of seconds since 00:00:00, January 1st 1970 UTC). This - must be in the UTC time zone. + scrobbles after this time, in Unix timestamp format (integer + number of seconds since 00:00:00, January 1st 1970 UTC). to (Optional) : End timestamp of a range - only display scrobbles - before this time, in UNIX timestamp format (integer number of - seconds since 00:00:00, January 1st 1970 UTC). This must be in - the UTC time zone. + before this time, in Unix timestamp format (integer number of + seconds since 00:00:00, January 1st 1970 UTC). stream: If True, it will yield tracks as soon as a page has been retrieved. This method uses caching. Enable caching only if you're pulling a @@ -2382,7 +2380,7 @@ class User(_Chartable): return _extract(doc, "registered") def get_unixtime_registered(self): - """Returns the user's registration date as a UNIX timestamp.""" + """Returns the user's registration date as a Unix timestamp.""" doc = self._request(self.ws_prefix + ".getInfo", True) From e90d717b66c01d9ddb0dac52d31d1c9a478b11e7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 01:18:15 +0000 Subject: [PATCH 745/768] Update github-actions --- .github/workflows/deploy.yml | 6 +++--- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 924a079..3e3174a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,7 +25,7 @@ jobs: with: fetch-depth: 0 - - uses: hynek/build-and-inspect-python-package@v1 + - uses: hynek/build-and-inspect-python-package@v2 # Upload to Test PyPI on every commit on main. release-test-pypi: @@ -40,7 +40,7 @@ jobs: steps: - name: Download packages built by build-and-inspect-python-package - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: Packages path: dist @@ -63,7 +63,7 @@ jobs: steps: - name: Download packages built by build-and-inspect-python-package - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: Packages path: dist diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4cf8d37..f8f9a2b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.x" - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e8f5f4..bf80524 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From d5fe263c239bdae27d733b90896dceedbf83f29c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 10:31:29 -0700 Subject: [PATCH 746/768] [pre-commit.ci] pre-commit autoupdate (#444) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Hugo van Kemenade --- .pre-commit-config.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bb81967..c5ed648 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.13.0 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py38-plus] - - repo: https://github.com/psf/black - rev: 23.9.1 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.12.1 hooks: - id: black @@ -18,7 +18,7 @@ repos: additional_dependencies: [black==23.3.0] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort @@ -35,7 +35,7 @@ repos: - id: python-no-log-warn - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-case-conflict - id: check-merge-conflict @@ -48,13 +48,13 @@ repos: exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.2.0 + rev: 1.5.3 hooks: - id: pyproject-fmt additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.14 + rev: v0.15 hooks: - id: validate-pyproject From 370ff77f21635dc9579e1ab49b15010a84ffcef8 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 22 Jan 2024 09:23:41 +0200 Subject: [PATCH 747/768] Double read timeout to fix 'The read operation timed out' 5 seconds is the default --- src/pylast/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index c94e714..d180a48 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -894,6 +894,7 @@ class _Request: username = "" if username is None else f"?username={username}" (host_name, host_subdir) = self.network.ws_server + timeout = httpx.Timeout(5, read=10) if self.network.is_proxy_enabled(): client = httpx.Client( @@ -901,12 +902,14 @@ class _Request: base_url=f"https://{host_name}", headers=HEADERS, proxies=self.network.proxy, + timeout=timeout, ) else: client = httpx.Client( verify=SSL_CONTEXT, base_url=f"https://{host_name}", headers=HEADERS, + timeout=timeout, ) try: From ffebde28e2948e44ccf94bcec70f2abd528ca425 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 4 Feb 2024 18:59:28 +0000 Subject: [PATCH 748/768] Update github-actions --- .github/workflows/release-drafter.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 3f24b79..0910f73 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -29,6 +29,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf80524..f1cd738 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 6c888343c81c853b285f96748e79a2230e58c6d8 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 Feb 2024 12:00:33 -0700 Subject: [PATCH 749/768] Pin codecov/codecov-action to v3.1.5 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1cd738..4e94e7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3.1.5 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 36d89a69e8d88a762806ace12e876e1b5c10054d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 Feb 2024 21:06:19 +0200 Subject: [PATCH 750/768] Replace Flake8 with Ruff --- .flake8 | 2 -- .pre-commit-config.yaml | 40 ++++++++++++++-------------------------- pyproject.toml | 27 +++++++++++++++++++++++++-- src/pylast/__init__.py | 12 +++++++----- tests/test_album.py | 2 ++ tests/test_artist.py | 2 ++ tests/test_country.py | 2 ++ tests/test_library.py | 2 ++ tests/test_librefm.py | 2 ++ tests/test_network.py | 3 +++ tests/test_pylast.py | 2 ++ tests/test_tag.py | 2 ++ tests/test_track.py | 3 +++ tests/test_user.py | 2 ++ tests/unicode_test.py | 4 +++- 15 files changed, 71 insertions(+), 36 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 2bcd70e..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 88 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5ed648..5caa25a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.0 hooks: - - id: pyupgrade - args: [--py38-plus] + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black @@ -15,24 +15,7 @@ repos: hooks: - id: blacken-docs args: [--target-version=py38] - additional_dependencies: [black==23.3.0] - - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-2020, flake8-implicit-str-concat] - - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.10.0 - hooks: - - id: python-check-blanket-noqa - - id: python-no-log-warn + additional_dependencies: [black] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 @@ -42,19 +25,19 @@ repos: - id: check-json - id: check-toml - id: check-yaml + - id: debug-statements - id: end-of-file-fixer - - id: requirements-txt-fixer - id: trailing-whitespace exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.5.3 + rev: 1.7.0 hooks: - id: pyproject-fmt additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.15 + rev: v0.16 hooks: - id: validate-pyproject @@ -63,5 +46,10 @@ repos: hooks: - id: tox-ini-fmt + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes + ci: autoupdate_schedule: quarterly diff --git a/pyproject.toml b/pyproject.toml index 52292bb..3413855 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,5 +59,28 @@ version.source = "vcs" [tool.hatch.version.raw-options] local_scheme = "no-local-version" -[tool.isort] -profile = "black" +[tool.ruff.lint] +select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks + "RUF100", # unused noqa (yesqa) + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 +] +extend-ignore = [ + "E203", # Whitespace before ':' + "E221", # Multiple spaces before operator + "E226", # Missing whitespace around arithmetic operator + "E241", # Multiple spaces after ',' +] + +[tool.ruff.lint.isort] +known-first-party = ["pylast"] +required-imports = ["from __future__ import annotations"] diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index d180a48..0396f5b 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -628,7 +628,6 @@ class _Network: class LastFMNetwork(_Network): - """A Last.fm network object api_key: a provided API_KEY @@ -1243,8 +1242,10 @@ class _Chartable(_BaseObject): from_date value to the to_date value. chart_kind should be one of "album", "artist" or "track" """ + import sys + method = ".getWeekly" + chart_kind.title() + "Chart" - chart_type = eval(chart_kind.title()) # string to type + chart_type = getattr(sys.modules[__name__], chart_kind.title()) params = self._get_params() if from_date and to_date: @@ -1356,11 +1357,11 @@ class _Taggable(_BaseObject): new_tags.append(tag) for i in range(0, len(old_tags)): - if not c_old_tags[i] in c_new_tags: + if c_old_tags[i] not in c_new_tags: to_remove.append(old_tags[i]) for i in range(0, len(new_tags)): - if not c_new_tags[i] in c_old_tags: + if c_new_tags[i] not in c_old_tags: to_add.append(new_tags[i]) self.remove_tags(to_remove) @@ -2778,7 +2779,8 @@ def _collect_nodes( main.getAttribute("totalPages") or main.getAttribute("totalpages") ) else: - raise PyLastError("No total pages attribute") + msg = "No total pages attribute" + raise PyLastError(msg) for node in main.childNodes: if not node.nodeType == xml.dom.Node.TEXT_NODE and ( diff --git a/tests/test_album.py b/tests/test_album.py index a56faf1..1146f12 100755 --- a/tests/test_album.py +++ b/tests/test_album.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import pylast from .test_pylast import TestPyLastWithLastFm diff --git a/tests/test_artist.py b/tests/test_artist.py index e72474e..d4f9134 100755 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import pytest import pylast diff --git a/tests/test_country.py b/tests/test_country.py index 6d36ef3..1636b96 100755 --- a/tests/test_country.py +++ b/tests/test_country.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import pylast from .test_pylast import TestPyLastWithLastFm diff --git a/tests/test_library.py b/tests/test_library.py index cc35233..592436d 100755 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import pylast from .test_pylast import TestPyLastWithLastFm diff --git a/tests/test_librefm.py b/tests/test_librefm.py index 01c43d9..0d9e839 100755 --- a/tests/test_librefm.py +++ b/tests/test_librefm.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + from flaky import flaky import pylast diff --git a/tests/test_network.py b/tests/test_network.py index 51f0b18..05672d6 100755 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,6 +1,9 @@ """ Integration (not unit) tests for pylast.py """ + +from __future__ import annotations + import re import time diff --git a/tests/test_pylast.py b/tests/test_pylast.py index 4beeed7..c06a9c3 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import os import time diff --git a/tests/test_tag.py b/tests/test_tag.py index 89080f6..7a9675c 100755 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import pylast from .test_pylast import TestPyLastWithLastFm diff --git a/tests/test_track.py b/tests/test_track.py index dae2c9c..db9c69c 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -1,6 +1,9 @@ """ Integration (not unit) tests for pylast.py """ + +from __future__ import annotations + import time import pytest diff --git a/tests/test_user.py b/tests/test_user.py index 12308eb..f5069d5 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -2,6 +2,8 @@ """ Integration (not unit) tests for pylast.py """ +from __future__ import annotations + import calendar import datetime as dt import inspect diff --git a/tests/unicode_test.py b/tests/unicode_test.py index bc93dfa..67f234b 100644 --- a/tests/unicode_test.py +++ b/tests/unicode_test.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from unittest import mock import pytest @@ -25,7 +27,7 @@ def test_get_cache_key(artist) -> None: @pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())]) def test_cast_and_hash(obj) -> None: - assert type(str(obj)) is str + assert isinstance(str(obj), str) assert isinstance(hash(obj), int) From 5bccda1102ca7b67c8b92b3fd18733f75df3225b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 Feb 2024 22:02:41 +0200 Subject: [PATCH 751/768] Update config --- .github/workflows/deploy.yml | 11 +++++++---- .github/workflows/labels.yml | 3 +++ .github/workflows/lint.yml | 3 +++ .github/workflows/require-pr-label.yml | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3e3174a..8b9a278 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,12 +30,14 @@ jobs: # Upload to Test PyPI on every commit on main. release-test-pypi: name: Publish in-dev package to test.pypi.org - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: | + github.repository_owner == 'pylast' + && github.event_name == 'push' + && github.ref == 'refs/heads/main' runs-on: ubuntu-latest needs: build-package permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: @@ -53,12 +55,13 @@ jobs: # Upload to real PyPI on GitHub Releases. release-pypi: name: Publish released package to pypi.org - if: github.event.action == 'published' + if: | + github.repository_owner == 'pylast' + && github.event.action == 'published' runs-on: ubuntu-latest needs: build-package permissions: - # IMPORTANT: this permission is mandatory for trusted publishing id-token: write steps: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 9ca7454..859c948 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,5 +1,8 @@ name: Sync labels +permissions: + pull-requests: write + on: push: branches: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f8f9a2b..dae63b0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ name: Lint on: [push, pull_request, workflow_dispatch] +env: + FORCE_COLOR: 1 + permissions: contents: read diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml index 85c3e3e..0d910db 100644 --- a/.github/workflows/require-pr-label.yml +++ b/.github/workflows/require-pr-label.yml @@ -10,6 +10,7 @@ jobs: permissions: issues: write + pull-requests: write steps: - uses: mheap/github-action-required-labels@v5 From 3890cb4c04fa72bd1003ecaa42a8020b78e79e5d Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Sun, 4 Feb 2024 22:03:26 +0200 Subject: [PATCH 752/768] Add {envpython} and --cov-report html, multiline for clarity --- tox.ini | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b0d3588..e3972c0 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,14 @@ pass_env = PYLAST_PASSWORD_HASH PYLAST_USERNAME commands = - pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --cov-report xml --random-order {posargs} + {envpython} -m pytest -v -s -W all \ + --cov pylast \ + --cov tests \ + --cov-report html \ + --cov-report term-missing \ + --cov-report xml \ + --random-order \ + {posargs} [testenv:lint] skip_install = true From 77d1b0009c46410057d7b4a563c1c8968dd2ec75 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 29 Dec 2023 21:04:31 +0200 Subject: [PATCH 753/768] Add support for Python 3.13 --- .github/workflows/test.yml | 3 +-- README.md | 1 + pyproject.toml | 1 + tox.ini | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e94e7a..7f09cba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest] steps: @@ -24,7 +24,6 @@ jobs: allow-prereleases: true cache: pip - - name: Install dependencies run: | python -m pip install -U pip diff --git a/README.md b/README.md index 3d9e882..31ea52c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Or from requirements.txt: Note: +* pyLast 5.3+ supports Python 3.8-3.13. * pyLast 5.2+ supports Python 3.8-3.12. * pyLast 5.1 supports Python 3.7-3.11. * pyLast 5.0 supports Python 3.7-3.10. diff --git a/pyproject.toml b/pyproject.toml index 3413855..6857849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet", diff --git a/tox.ini b/tox.ini index e3972c0..3ead5fc 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ requires = tox>=4.2 env_list = lint - py{py3, 312, 311, 310, 39, 38} + py{py3, 313, 312, 311, 310, 39, 38} [testenv] extras = From a14a50a333b0793f5a648384b257c8ed6634a58b Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:22:18 +0200 Subject: [PATCH 754/768] Scrutinizer was removed in 2019 --- .scrutinizer.yml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 .scrutinizer.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 43dbfa3..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,9 +0,0 @@ -checks: - python: - code_rating: true - duplicate_code: true -filter: - excluded_paths: - - '*/test/*' -tools: - external_code_coverage: true From f4547a58213dc2520aff5f002d98092f1b285280 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Mon, 5 Feb 2024 21:24:25 +0200 Subject: [PATCH 755/768] Add Prettier to pre-commit --- .pre-commit-config.yaml | 7 +++ CHANGELOG.md | 96 ++++++++++++++++++++++------------------ README.md | 48 ++++++++++---------- RELEASING.md | 7 +-- example_test_pylast.yaml | 8 ++-- 5 files changed, 90 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5caa25a..028e611 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,13 @@ repos: hooks: - id: tox-ini-fmt + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + args: [--prose-wrap=always, --print-width=88] + exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md + - repo: meta hooks: - id: check-hooks-apply diff --git a/CHANGELOG.md b/CHANGELOG.md index 2518770..d424974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,117 +12,125 @@ See GitHub Releases: ## Changed -* Fix unsafe creation of temp file for caching, and improve exception raising (#356) @kvanzuijlen -* [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci - +- Fix unsafe creation of temp file for caching, and improve exception raising (#356) + @kvanzuijlen +- [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci ## [4.1.0] - 2021-01-04 + ## Added -* Add support for streaming (#336) @kvanzuijlen -* Add Python 3.9 final to Travis CI (#350) @sheetalsingala +- Add support for streaming (#336) @kvanzuijlen +- Add Python 3.9 final to Travis CI (#350) @sheetalsingala ## Changed -* Update copyright year (#360) @hugovk -* Replace Travis CI with GitHub Actions (#352) @hugovk -* [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci +- Update copyright year (#360) @hugovk +- Replace Travis CI with GitHub Actions (#352) @hugovk +- [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci ## Fixed -* Set limit to 50 by default, not 1 (#355) @hugovk - +- Set limit to 50 by default, not 1 (#355) @hugovk ## [4.0.0] - 2020-10-07 + ## Added -* Add support for Python 3.9 (#347) @hugovk +- Add support for Python 3.9 (#347) @hugovk ## Removed -* Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and `STATUS_TOKEN_ERROR` (#348) @hugovk -* Drop support for EOL Python 3.5 (#346) @hugovk - +- Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and + `STATUS_TOKEN_ERROR` (#348) @hugovk +- Drop support for EOL Python 3.5 (#346) @hugovk ## [3.3.0] - 2020-06-25 + ### Added -* `User.get_now_playing`: Add album and cover image to info (#330) @hugovk +- `User.get_now_playing`: Add album and cover image to info (#330) @hugovk ### Changed -* Improve handling of error responses from the API (#327) @spiritualized +- Improve handling of error responses from the API (#327) @spiritualized ### Deprecated -* Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) @hugovk +- Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) + @hugovk ### Fixed -* Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk - +- Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk ## [3.2.1] - 2020-03-05 + ### Fixed -* Only Python 3 is supported: don't create universal wheel (#318) @hugovk -* Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk -* Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk +- Only Python 3 is supported: don't create universal wheel (#318) @hugovk +- Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk +- Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk ## [3.2.0] - 2020-01-03 + ### Added -* Support for Python 3.8 -* Store album art URLs when you call `GetTopAlbums` ([#307]) -* Retry paging through results on exception ([#297]) -* More error status codes from https://last.fm/api/errorcodes ([#297]) +- Support for Python 3.8 +- Store album art URLs when you call `GetTopAlbums` ([#307]) +- Retry paging through results on exception ([#297]) +- More error status codes from https://last.fm/api/errorcodes ([#297]) ### Changed -* Respect `get_recent_tracks`' limit when there's a now playing track ([#310]) -* Move installable code to `src/` ([#301]) -* Update `get_weekly_artist_charts` docstring: only for `User` ([#311]) -* Remove Python 2 warnings, `python_requires` should be enough ([#312]) -* Use setuptools_scm to simplify versioning during release ([#316]) -* Various lint and test updates +- Respect `get_recent_tracks`' limit when there's a now playing track ([#310]) +- Move installable code to `src/` ([#301]) +- Update `get_weekly_artist_charts` docstring: only for `User` ([#311]) +- Remove Python 2 warnings, `python_requires` should be enough ([#312]) +- Use setuptools_scm to simplify versioning during release ([#316]) +- Various lint and test updates ### Deprecated -* Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer +- Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer available. Last.fm returns a "Deprecated - This type of request is no longer supported" error when calling it. A future version of pylast will remove its `User.get_artist_tracks` altogether. ([#305]) -* `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. - Use `STATUS_OPERATION_FAILED` instead. +- `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. Use + `STATUS_OPERATION_FAILED` instead. ## [3.1.0] - 2019-03-07 + ### Added -* Extract username from session via new +- Extract username from session via new `SessionKeyGenerator.get_web_auth_session_key_username` ([#290]) -* `User.get_track_scrobbles` ([#298]) +- `User.get_track_scrobbles` ([#298]) ### Deprecated -* `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement. - ([#298]) +- `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement. + ([#298]) ## [3.0.0] - 2019-01-01 + ### Added -* This changelog file ([#273]) + +- This changelog file ([#273]) ### Removed -* Support for Python 2.7 ([#265]) +- Support for Python 2.7 ([#265]) -* Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` - and `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282]) +- Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` and + `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282]) ## [2.4.0] - 2018-08-08 + ### Deprecated -* Support for Python 2.7 ([#265]) +- Support for Python 2.7 ([#265]) [4.2.0]: https://github.com/pylast/pylast/compare/4.1.0...4.2.0 [4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0 diff --git a/README.md b/README.md index 31ea52c..93bd9b6 100644 --- a/README.md +++ b/README.md @@ -35,31 +35,30 @@ Or from requirements.txt: Note: -* pyLast 5.3+ supports Python 3.8-3.13. -* pyLast 5.2+ supports Python 3.8-3.12. -* pyLast 5.1 supports Python 3.7-3.11. -* pyLast 5.0 supports Python 3.7-3.10. -* pyLast 4.3 - 4.5 supports Python 3.6-3.10. -* pyLast 4.0 - 4.2 supports Python 3.6-3.9. -* pyLast 3.2 - 3.3 supports Python 3.5-3.8. -* pyLast 3.0 - 3.1 supports Python 3.5-3.7. -* pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. -* pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6. -* pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6. -* pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4. -* pyLast 0.5 supports Python 2, 3. -* pyLast < 0.5 supports Python 2. +- pyLast 5.3+ supports Python 3.8-3.13. +- pyLast 5.2+ supports Python 3.8-3.12. +- pyLast 5.1 supports Python 3.7-3.11. +- pyLast 5.0 supports Python 3.7-3.10. +- pyLast 4.3 - 4.5 supports Python 3.6-3.10. +- pyLast 4.0 - 4.2 supports Python 3.6-3.9. +- pyLast 3.2 - 3.3 supports Python 3.5-3.8. +- pyLast 3.0 - 3.1 supports Python 3.5-3.7. +- pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7. +- pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6. +- pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6. +- pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4. +- pyLast 0.5 supports Python 2, 3. +- pyLast < 0.5 supports Python 2. ## Features - * Simple public interface. - * Access to all the data exposed by the Last.fm web services. - * Scrobbling support. - * Full object-oriented design. - * Proxy support. - * Internal caching support for some web services calls (disabled by default). - * Support for other API-compatible networks like Libre.fm. - +- Simple public interface. +- Access to all the data exposed by the Last.fm web services. +- Scrobbling support. +- Full object-oriented design. +- Proxy support. +- Internal caching support for some web services calls (disabled by default). +- Support for other API-compatible networks like Libre.fm. ## Getting started @@ -88,8 +87,8 @@ network = pylast.LastFMNetwork( ) ``` -Alternatively, instead of creating `network` with a username and password, -you can authenticate with a session key: +Alternatively, instead of creating `network` with a username and password, you can +authenticate with a session key: ```python import pylast @@ -132,7 +131,6 @@ track.add_tags(("awesome", "favorite")) # to get more help about anything and see examples of how it works ``` - More examples in hugovk/lastfm-tools and [tests/](https://github.com/pylast/pylast/tree/main/tests). diff --git a/RELEASING.md b/RELEASING.md index 9a4d1bc..9b2e38a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,8 +1,8 @@ # Release Checklist - [ ] Get `main` to the appropriate code release state. - [GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for - all merges to `main`. + [GitHub Actions](https://github.com/pylast/pylast/actions) should be running + cleanly for all merges to `main`. [![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions) - [ ] Edit release draft, adjust text if needed: @@ -12,7 +12,8 @@ - [ ] Publish release -- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml) +- [ ] Check the tagged + [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml) has deployed to [PyPI](https://pypi.org/project/pylast/#history) - [ ] Check installation: diff --git a/example_test_pylast.yaml b/example_test_pylast.yaml index a8fa045..00b09f1 100644 --- a/example_test_pylast.yaml +++ b/example_test_pylast.yaml @@ -1,4 +1,4 @@ -username: TODO_ENTER_YOURS_HERE -password_hash: TODO_ENTER_YOURS_HERE -api_key: TODO_ENTER_YOURS_HERE -api_secret: TODO_ENTER_YOURS_HERE +username: TODO_ENTER_YOURS_HERE +password_hash: TODO_ENTER_YOURS_HERE +api_key: TODO_ENTER_YOURS_HERE +api_secret: TODO_ENTER_YOURS_HERE From d5f1c3d3ac6dac984de011c47aa346616ac1dfe7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 01:13:50 +0000 Subject: [PATCH 756/768] Update github-actions --- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dae63b0..88c0c7c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,4 +17,4 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.x" - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f09cba..9c2c1a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v3.1.5 + uses: codecov/codecov-action@v4.1.0 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From e4d2ebc4a06e598fcaa7254f75868af04dae5753 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Fri, 1 Mar 2024 08:02:36 +0200 Subject: [PATCH 757/768] Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c2c1a3..7f09cba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }} - name: Upload coverage - uses: codecov/codecov-action@v4.1.0 + uses: codecov/codecov-action@v3.1.5 with: flags: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} From 82cb5048717f460d7da658ff367690e1ab696999 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 17:16:48 +0000 Subject: [PATCH 758/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.2.0 → v0.3.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.0...v0.3.4) - [github.com/psf/black-pre-commit-mirror: 24.1.1 → 24.3.0](https://github.com/psf/black-pre-commit-mirror/compare/24.1.1...24.3.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 028e611..89f4d05 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.0 + rev: v0.3.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.1.1 + rev: 24.3.0 hooks: - id: black From fa68aa4ae867ab65f99ba21b5898668fb54ec669 Mon Sep 17 00:00:00 2001 From: Christian McKinnon Date: Wed, 22 May 2024 11:48:48 +0700 Subject: [PATCH 759/768] Update example_test_pylast.yaml Link in README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93bd9b6..7653b86 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,10 @@ The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contain integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with -test data, and an API key and secret. Either copy -[example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out -the credentials, or set them as environment variables like: +test data, and an API key and secret. Either copy [example_test_pylast.yaml]( +https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml) to +test_pylast.yaml and fill out the credentials, or set them as environment +variables like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE From 90c3614d6ac969d471e5e6e8d5d022496fef4cad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 May 2024 05:10:35 +0000 Subject: [PATCH 760/768] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7653b86..de2987f 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,10 @@ The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contain integration and unit tests with Last.fm, and plenty of code examples. For integration tests you need a test account at Last.fm that will become cluttered with -test data, and an API key and secret. Either copy [example_test_pylast.yaml]( -https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml) to -test_pylast.yaml and fill out the credentials, or set them as environment -variables like: +test data, and an API key and secret. Either copy +[example_test_pylast.yaml](https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml) +to test_pylast.yaml and fill out the credentials, or set them as environment variables +like: ```sh export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE From 353e32bd6b7eb9cf3cd7be2797d344255df74707 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 22 May 2024 22:26:42 +0300 Subject: [PATCH 761/768] Fix expected result in test --- tests/test_track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_track.py b/tests/test_track.py index db9c69c..8d8a275 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -140,7 +140,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): # Assert found = False for track in similar: - if str(track.item) == "Madonna - Vogue": + if str(track.item) == "Cher - Strong Enough": found = True break assert found From 35b264bae471cfef1f13663d17ea4777c166240a Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 22 May 2024 22:29:05 +0300 Subject: [PATCH 762/768] Refactor --- tests/test_track.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_track.py b/tests/test_track.py index 8d8a275..db04d15 100755 --- a/tests/test_track.py +++ b/tests/test_track.py @@ -138,11 +138,7 @@ class TestPyLastTrack(TestPyLastWithLastFm): similar = track.get_similar() # Assert - found = False - for track in similar: - if str(track.item) == "Cher - Strong Enough": - found = True - break + found = any(str(track.item) == "Cher - Strong Enough" for track in similar) assert found def test_track_get_similar_limits(self) -> None: From 6f97f93dcc9b5f16c0da8a59c42adde9593ee61c Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 22 May 2024 22:46:47 +0300 Subject: [PATCH 763/768] Update config --- .github/workflows/lint.yml | 2 ++ .pre-commit-config.yaml | 23 ++++++++++++++++++----- pyproject.toml | 1 + 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 88c0c7c..d553e49 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,7 @@ on: [push, pull_request, workflow_dispatch] env: FORCE_COLOR: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 permissions: contents: read @@ -17,4 +18,5 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.x" + cache: pip - uses: pre-commit/action@v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89f4d05..3d54f50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 + rev: v0.4.4 hooks: - id: ruff - args: [--fix, --exit-non-zero-on-fix] + args: [--exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.3.0 + rev: 24.4.2 hooks: - id: black @@ -18,8 +18,9 @@ repos: additional_dependencies: [black] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: + - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-json @@ -27,9 +28,21 @@ repos: - id: check-yaml - id: debug-statements - id: end-of-file-fixer + - id: forbid-submodules - id: trailing-whitespace exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.4 + hooks: + - id: check-github-workflows + - id: check-renovate + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.0 + hooks: + - id: actionlint + - repo: https://github.com/tox-dev/pyproject-fmt rev: 1.7.0 hooks: @@ -37,7 +50,7 @@ repos: additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.18 hooks: - id: validate-pyproject diff --git a/pyproject.toml b/pyproject.toml index 6857849..9887d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ select = [ "LOG", # flake8-logging "PGH", # pygrep-hooks "RUF100", # unused noqa (yesqa) + "RUF022", # unsorted-dunder-all "UP", # pyupgrade "W", # pycodestyle warnings "YTT", # flake8-2020 From aceaa69c9afab935858820491169e251ee2e0b06 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:24:06 +0000 Subject: [PATCH 764/768] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.4.4 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.4...v0.5.0) - [github.com/asottile/blacken-docs: 1.16.0 → 1.18.0](https://github.com/asottile/blacken-docs/compare/1.16.0...1.18.0) - [github.com/python-jsonschema/check-jsonschema: 0.28.4 → 0.28.6](https://github.com/python-jsonschema/check-jsonschema/compare/0.28.4...0.28.6) - [github.com/rhysd/actionlint: v1.7.0 → v1.7.1](https://github.com/rhysd/actionlint/compare/v1.7.0...v1.7.1) - [github.com/tox-dev/pyproject-fmt: 1.7.0 → 2.1.3](https://github.com/tox-dev/pyproject-fmt/compare/1.7.0...2.1.3) --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d54f50..0895936 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.4 + rev: v0.5.0 hooks: - id: ruff args: [--exit-non-zero-on-fix] @@ -11,7 +11,7 @@ repos: - id: black - repo: https://github.com/asottile/blacken-docs - rev: 1.16.0 + rev: 1.18.0 hooks: - id: blacken-docs args: [--target-version=py38] @@ -33,18 +33,18 @@ repos: exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.4 + rev: 0.28.6 hooks: - id: check-github-workflows - id: check-renovate - repo: https://github.com/rhysd/actionlint - rev: v1.7.0 + rev: v1.7.1 hooks: - id: actionlint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.7.0 + rev: 2.1.3 hooks: - id: pyproject-fmt additional_dependencies: [tox] From 184d0328a97f76572da2e89c0ba8d6c5b2d9eada Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:29:20 +0300 Subject: [PATCH 765/768] Configure pyproject-fmt to add Python 3.13 classifier --- .pre-commit-config.yaml | 1 - pyproject.toml | 63 +++++++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0895936..477419b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,7 +47,6 @@ repos: rev: 2.1.3 hooks: - id: pyproject-fmt - additional_dependencies: [tox] - repo: https://github.com/abravalheri/validate-pyproject rev: v0.18 diff --git a/pyproject.toml b/pyproject.toml index 9887d1f..0586bb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,13 @@ keywords = [ "scrobble", "scrobbling", ] -license = {text = "Apache-2.0"} -maintainers = [{name = "Hugo van Kemenade"}] -authors = [{name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com"}] +license = { text = "Apache-2.0" } +maintainers = [ + { name = "Hugo van Kemenade" }, +] +authors = [ + { name = "Amr Hassan and Contributors", email = "amr.hassan@gmail.com" }, +] requires-python = ">=3.8" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -41,18 +45,16 @@ dynamic = [ dependencies = [ "httpx", ] -[project.optional-dependencies] -tests = [ +optional-dependencies.tests = [ "flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml", ] -[project.urls] -Changelog = "https://github.com/pylast/pylast/releases" -Homepage = "https://github.com/pylast/pylast" -Source = "https://github.com/pylast/pylast" +urls.Changelog = "https://github.com/pylast/pylast/releases" +urls.Homepage = "https://github.com/pylast/pylast" +urls.Source = "https://github.com/pylast/pylast" [tool.hatch] version.source = "vcs" @@ -60,29 +62,36 @@ version.source = "vcs" [tool.hatch.version.raw-options] local_scheme = "no-local-version" -[tool.ruff.lint] -select = [ - "C4", # flake8-comprehensions - "E", # pycodestyle errors - "EM", # flake8-errmsg - "F", # pyflakes errors - "I", # isort - "ISC", # flake8-implicit-str-concat - "LOG", # flake8-logging - "PGH", # pygrep-hooks - "RUF100", # unused noqa (yesqa) +[tool.ruff] +fix = true + +lint.select = [ + "C4", # flake8-comprehensions + "E", # pycodestyle errors + "EM", # flake8-errmsg + "F", # pyflakes errors + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "PGH", # pygrep-hooks "RUF022", # unsorted-dunder-all - "UP", # pyupgrade - "W", # pycodestyle warnings - "YTT", # flake8-2020 + "RUF100", # unused noqa (yesqa) + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 ] -extend-ignore = [ +lint.extend-ignore = [ "E203", # Whitespace before ':' "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' ] +lint.isort.known-first-party = [ + "pylast", +] +lint.isort.required-imports = [ + "from __future__ import annotations", +] -[tool.ruff.lint.isort] -known-first-party = ["pylast"] -required-imports = ["from __future__ import annotations"] +[tool.pyproject-fmt] +max_supported_python = "3.13" From c260d7b83fe3938a6163db2d3efd2cd734b060c2 Mon Sep 17 00:00:00 2001 From: Hirad Date: Sun, 7 Jul 2024 09:03:18 +0330 Subject: [PATCH 766/768] change libre.fm to music.lonestar.it --- src/pylast/__init__.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index 0396f5b..bd856bc 100644 --- a/src/pylast/__init__.py +++ b/src/pylast/__init__.py @@ -1,6 +1,6 @@ # # pylast - -# A Python interface to Last.fm and Libre.fm +# A Python interface to Last.fm and music.lonestar.it # # Copyright 2008-2010 Amr Hassan # Copyright 2013-2021 hugovk @@ -705,7 +705,7 @@ class LastFMNetwork(_Network): class LibreFMNetwork(_Network): """ - A preconfigured _Network object for Libre.fm + A preconfigured _Network object for music.lonestar.it api_key: a provided API_KEY api_secret: a provided API_SECRET @@ -727,27 +727,27 @@ class LibreFMNetwork(_Network): password_hash: str = "", ) -> None: super().__init__( - name="Libre.fm", - homepage="https://libre.fm", - ws_server=("libre.fm", "/2.0/"), + name="music.lonestar.it", + homepage="https://music.lonestar.it", + ws_server=("music.lonestar.it", "/2.0/"), api_key=api_key, api_secret=api_secret, session_key=session_key, username=username, password_hash=password_hash, domain_names={ - DOMAIN_ENGLISH: "libre.fm", - DOMAIN_GERMAN: "libre.fm", - DOMAIN_SPANISH: "libre.fm", - DOMAIN_FRENCH: "libre.fm", - DOMAIN_ITALIAN: "libre.fm", - DOMAIN_POLISH: "libre.fm", - DOMAIN_PORTUGUESE: "libre.fm", - DOMAIN_SWEDISH: "libre.fm", - DOMAIN_TURKISH: "libre.fm", - DOMAIN_RUSSIAN: "libre.fm", - DOMAIN_JAPANESE: "libre.fm", - DOMAIN_CHINESE: "libre.fm", + DOMAIN_ENGLISH: "music.lonestar.it", + DOMAIN_GERMAN: "music.lonestar.it", + DOMAIN_SPANISH: "music.lonestar.it", + DOMAIN_FRENCH: "music.lonestar.it", + DOMAIN_ITALIAN: "music.lonestar.it", + DOMAIN_POLISH: "music.lonestar.it", + DOMAIN_PORTUGUESE: "music.lonestar.it", + DOMAIN_SWEDISH: "music.lonestar.it", + DOMAIN_TURKISH: "music.lonestar.it", + DOMAIN_RUSSIAN: "music.lonestar.it", + DOMAIN_JAPANESE: "music.lonestar.it", + DOMAIN_CHINESE: "music.lonestar.it", }, urls={ "album": "artist/%(artist)s/album/%(album)s", From 6ae051157fbfd3daa91bcd54663804277cf054ec Mon Sep 17 00:00:00 2001 From: Hirad Date: Sun, 7 Jul 2024 09:16:25 +0330 Subject: [PATCH 767/768] Update README.md --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index de2987f..2b569d3 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,10 @@ Use the pydoc utility for help on usage or see [tests/](tests/) for examples. ## Installation -Install via pip: - -```sh -python3 -m pip install pylast -``` - Install latest development version: ```sh -python3 -m pip install -U git+https://github.com/pylast/pylast +python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast ``` Or from requirements.txt: From 88bb8ea78919d4178e52a07eb45db9e13ed73b1b Mon Sep 17 00:00:00 2001 From: Hirad Date: Sun, 7 Jul 2024 09:16:54 +0330 Subject: [PATCH 768/768] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2b569d3..c22fbec 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast Or from requirements.txt: ```txt --e https://github.com/pylast/pylast.git#egg=pylast +-e https://git.hirad.it/Hirad/pylast#egg=pylast ``` Note: