From 3d9e863cd3318851c0180885548c1c9c2c1469f2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 6 Sep 2016 10:32:05 +0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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]