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/.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 diff --git a/README.md b/README.md index d5bb5dd..d20a717 100644 --- a/README.md +++ b/README.md @@ -1,7 +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/) [![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 (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/). @@ -38,16 +43,16 @@ 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 -API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key +# Obtain yours from http://www.last.fm/api/account/create 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) +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") @@ -58,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). 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):