Merge pull request #189 from pylast/develop

Merge develop into master
This commit is contained in:
Hugo 2017-01-03 14:55:49 +01:00 committed by GitHub
commit a3cf6644fa
7 changed files with 189 additions and 60 deletions

6
.gitignore vendored
View file

@ -1,3 +1,6 @@
# User Credentials
test_pylast.yaml
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@ -57,3 +60,6 @@ docs/_build/
# PyBuilder # PyBuilder
target/ target/
# JetBrains
.idea/

View file

@ -1,4 +1,5 @@
language: python language: python
env: env:
global: global:
- secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY= - secure: ivg6II471E9HV8xyqnawLIuP/sZ0J63Y+BC0BQcRVKtLn/K3zmD1ozM3TFL9S549Nxd0FqDKHXJvXsgaTGIDpK8sxE2AMKV5IojyM0iAVuN7YjPK9vwSlRw1u0EysPMFqxOZVQnoDyHrSGIUrP/VMdnhBu6dbUX0FyEkvZshXhY=
@ -9,24 +10,39 @@ env:
- secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs= - secure: VPARlWNg/0Nit7a924vJlDfv7yiuTDtrcGZNFrZ6yN3dl8ZjVPizQXQNKA3yq0y2jW25nwjRwZYj3eY5MdM9F7Sw51d+/8AjFtdCuRgDvwlQFR/pCoyzqgJATkXKo7mlejvnA+5EKUzAmu3drIbboFgbLgRTMrG7b/ot9tazTHs=
- secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y= - secure: CQYL7MH6tSVrCcluIfWfDSTo4E/p+9pF0eI7Vtf0oaZBzyulODHK8h/mzJp4HwezyfOu0RCedq6sloGQr1/29CvWWESaYyoGoGz9Mz2ZS+MpIcjGISfZa+x4vSp6QPFvd4i/1Z/1j2gJVVyswkrIVUwZIDJtfAKzZI5iHx2gH8Y=
- secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic= - secure: SsKJoJwtDVWrL5xxl9C/gTRy6FhfRQQNNAFOogl9mTs/WeI2t9QTYoKsxLPXOdoRdu4MvT3h/B2sjwggt7zP81fBVxQRTkg4nq0zSHlj0NqclbFa6I5lUYdGwH9gPk/HWJJwXhKRDsqn/iRw2v+qBDs/j3kIgPQ0yjM58LEPXic=
matrix:
- TOXENV=lint matrix:
- TOXENV=py27 include:
- TOXENV=py33 - python: 2.7
- TOXENV=py34 env: TOXENV=lint
- TOXENV=pypy - python: 2.7
- TOXENV=pypy3 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 sudo: false
install: install:
- travis_retry pip install tox==2.1.1 - travis_retry pip install tox==2.1.1
- travis_retry pip install coveralls - travis_retry pip install coveralls
script: tox script: tox
after_success: after_success:
- travis_retry pip install coveralls && coveralls - travis_retry pip install coveralls && coveralls
- travis_retry pip install scrutinizer-ocular && ocular - travis_retry pip install scrutinizer-ocular && ocular
matrix:
allow_failures:
- python: '3.4'
- python: pypy
- python: pypy3
fast_finish: true

View file

@ -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) [![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. 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 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 ```python
import pylast import pylast
@ -49,7 +49,7 @@ 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) 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 = network.get_artist("System of a Down")
artist.shout("<3") artist.shout("<3")
@ -58,7 +58,7 @@ track = network.get_track("Iron Maiden", "The Nomad")
track.love() track.love()
track.add_tags(("awesome", "favorite")) 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 # 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. [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 ```sh
export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
@ -80,7 +80,7 @@ export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE
To run all unit and integration tests: To run all unit and integration tests:
```sh ```sh
pip install pytest flaky pip install pytest flaky mock
py.test py.test
``` ```

View file

@ -42,8 +42,23 @@ __email__ = 'amr.hassan@gmail.com'
def _deprecation_warning(message): def _deprecation_warning(message):
warnings.warn(message, DeprecationWarning) 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 sys.version_info[0] == 3:
from http.client import HTTPConnection if _can_use_ssl_securely():
from http.client import HTTPSConnection
else:
from http.client import HTTPConnection
import html.entities as htmlentitydefs import html.entities as htmlentitydefs
from urllib.parse import splithost as url_split_host from urllib.parse import splithost as url_split_host
from urllib.parse import quote_plus as url_quote_plus from urllib.parse import quote_plus as url_quote_plus
@ -51,7 +66,10 @@ if sys.version_info[0] == 3:
unichr = chr unichr = chr
elif sys.version_info[0] == 2: elif sys.version_info[0] == 2:
from httplib import HTTPConnection if _can_use_ssl_securely():
from httplib import HTTPSConnection
else:
from httplib import HTTPConnection
import htmlentitydefs import htmlentitydefs
from urllib import splithost as url_split_host from urllib import splithost as url_split_host
from urllib import quote_plus as url_quote_plus 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) 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): class _Network(object):
""" """
@ -915,8 +986,8 @@ class LibreFMNetwork(_Network):
_Network.__init__( _Network.__init__(
self, self,
name="Libre.fm", name="Libre.fm",
homepage="http://alpha.libre.fm", homepage="http://libre.fm",
ws_server=("alpha.libre.fm", "/2.0/"), ws_server=("libre.fm", "/2.0/"),
api_key=api_key, api_key=api_key,
api_secret=api_secret, api_secret=api_secret,
session_key=session_key, session_key=session_key,
@ -924,18 +995,18 @@ class LibreFMNetwork(_Network):
username=username, username=username,
password_hash=password_hash, password_hash=password_hash,
domain_names={ domain_names={
DOMAIN_ENGLISH: "alpha.libre.fm", DOMAIN_ENGLISH: "libre.fm",
DOMAIN_GERMAN: "alpha.libre.fm", DOMAIN_GERMAN: "libre.fm",
DOMAIN_SPANISH: "alpha.libre.fm", DOMAIN_SPANISH: "libre.fm",
DOMAIN_FRENCH: "alpha.libre.fm", DOMAIN_FRENCH: "libre.fm",
DOMAIN_ITALIAN: "alpha.libre.fm", DOMAIN_ITALIAN: "libre.fm",
DOMAIN_POLISH: "alpha.libre.fm", DOMAIN_POLISH: "libre.fm",
DOMAIN_PORTUGUESE: "alpha.libre.fm", DOMAIN_PORTUGUESE: "libre.fm",
DOMAIN_SWEDISH: "alpha.libre.fm", DOMAIN_SWEDISH: "libre.fm",
DOMAIN_TURKISH: "alpha.libre.fm", DOMAIN_TURKISH: "libre.fm",
DOMAIN_RUSSIAN: "alpha.libre.fm", DOMAIN_RUSSIAN: "libre.fm",
DOMAIN_JAPANESE: "alpha.libre.fm", DOMAIN_JAPANESE: "libre.fm",
DOMAIN_CHINESE: "alpha.libre.fm", DOMAIN_CHINESE: "libre.fm",
}, },
urls={ urls={
"album": "artist/%(artist)s/album/%(album)s", "album": "artist/%(artist)s/album/%(album)s",
@ -1098,9 +1169,15 @@ class _Request(object):
(HOST_NAME, HOST_SUBDIR) = self.network.ws_server (HOST_NAME, HOST_SUBDIR) = self.network.ws_server
if self.network.is_proxy_enabled(): if self.network.is_proxy_enabled():
conn = HTTPConnection( if _can_use_ssl_securely():
host=self.network._get_proxy()[0], conn = HTTPSConnection(
port=self.network._get_proxy()[1]) 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: try:
conn.request( conn.request(
@ -1110,7 +1187,15 @@ class _Request(object):
raise NetworkError(self.network, e) raise NetworkError(self.network, e)
else: else:
conn = HTTPConnection(host=HOST_NAME) if _can_use_ssl_securely():
conn = HTTPSConnection(
context=SSL_CONTEXT,
host=HOST_NAME
)
else:
conn = HTTPConnection(
host=HOST_NAME
)
try: try:
conn.request( conn.request(
@ -1681,15 +1766,15 @@ class WSError(Exception):
class MalformedResponseError(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): def __init__(self, network, underlying_error):
self.network = network self.network = network
self.underlying_error = underlying_error self.underlying_error = underlying_error
def __str__(self): def __str__(self):
return "Malformed response from Last.fm. Underlying error: %s" % str( return "Malformed response from {}. Underlying error: {}".format(
self.underlying_error) self.network.name, str(self.underlying_error))
class NetworkError(Exception): class NetworkError(Exception):
@ -4291,7 +4376,15 @@ class _ScrobblerRequest(object):
def execute(self): def execute(self):
"""Returns a string response of this request.""" """Returns a string response of this request."""
connection = HTTPConnection(self.hostname) if _can_use_ssl_securely():
connection = HTTPSConnection(
context=SSL_CONTEXT,
host=self.hostname
)
else:
connection = HTTPConnection(
host=self.hostname
)
data = [] data = []
for name in self.params.keys(): for name in self.params.keys():

View file

@ -7,6 +7,12 @@ setup(
version="1.6.0", version="1.6.0",
author="Amr Hassan <amr.hassan@gmail.com>", author="Amr Hassan <amr.hassan@gmail.com>",
install_requires=['six'], 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'], tests_require=['mock', 'pytest', 'coverage', 'pep8', 'pyyaml', 'pyflakes'],
description=("A Python interface to Last.fm and Libre.fm"), description=("A Python interface to Last.fm and Libre.fm"),
author_email="amr.hassan@gmail.com", author_email="amr.hassan@gmail.com",
@ -22,6 +28,8 @@ setup(
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
], ],
keywords=["Last.fm", "music", "scrobble", "scrobbling"], keywords=["Last.fm", "music", "scrobble", "scrobbling"],
packages=find_packages(exclude=('tests*',)), packages=find_packages(exclude=('tests*',)),

View file

@ -375,21 +375,6 @@ class TestPyLast(unittest.TestCase):
self.assertEqual(str(current_track.title), "Test Title") self.assertEqual(str(current_track.title), "Test Title")
self.assertEqual(str(current_track.artist), "Test Artist") 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 @handle_lastfm_exceptions
def test_album_tags_are_topitems(self): def test_album_tags_are_topitems(self):
# Arrange # Arrange
@ -2175,5 +2160,26 @@ class TestPyLast(unittest.TestCase):
# Assert # Assert
self.assertEqual(mbid, None) 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__': if __name__ == '__main__':
unittest.main(failfast=True) unittest.main(failfast=True)

View file

@ -1,5 +1,5 @@
[tox] [tox]
envlist = py34, py27, pypy, pypy3 envlist = py27, py36, py35, py34, pypy, pypy3
recreate = False recreate = False
[testenv] [testenv]