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 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 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/ 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 }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 13f3f43..bda0c64 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,34 +4,9 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-18.04 + 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 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/.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/.pre-commit-config.yaml b/.pre-commit-config.yaml index da1e286..147a7e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,42 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.6.1 + rev: v2.7.4 hooks: - id: pyupgrade - args: ["--py3-plus"] + args: ["--py36-plus"] - repo: https://github.com/psf/black - rev: 19.10b0 + 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: [] + - repo: https://github.com/PyCQA/isort + rev: 5.6.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] - - repo: https://github.com/asottile/seed-isort-config - rev: v2.2.0 - hooks: - - id: seed-isort-config - - - repo: https://github.com/timothycrosley/isort - rev: 4.3.21 - hooks: - - id: isort - - repo: https://github.com/pre-commit/pygrep-hooks - rev: v1.5.1 + rev: v1.7.0 hooks: - id: python-check-blanket-noqa - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.1.0 + rev: v3.3.0 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/.travis.yml b/.travis.yml deleted file mode 100644 index f904565..0000000 --- a/.travis.yml +++ /dev/null @@ -1,67 +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.8 - - python: 3.7 - - python: 3.6 - - python: 3.5 - - python: 3.9-dev - - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d7042..b1c3a61 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 diff --git a/README.md b/README.md index 0559b64..1cd834a 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/). @@ -31,11 +30,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-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 0.5 supports Python 2, 3. * pyLast < 0.5 supports Python 2. @@ -49,7 +50,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/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: diff --git a/setup.cfg b/setup.cfg index bf5350d..191fac9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,5 @@ [flake8] -ignore = W503 max_line_length = 88 [tool:isort] -known_third_party = flaky,pkg_resources,pylast,pytest,setuptools +profile = black diff --git a/setup.py b/setup.py index 7238518..a2d891f 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,10 +35,10 @@ 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", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py index fc71011..1a24622 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 @@ -146,29 +143,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 +206,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 +268,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() @@ -1150,12 +1147,12 @@ class _BaseObject: return first_child.wholeText.strip() def _get_things( - self, method, thing, thing_type, params=None, cacheable=True, stream=False + self, method, 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) + limit = params.get("limit", 50) nodes = _collect_nodes( limit, self, @@ -1416,31 +1413,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 @@ -1721,32 +1718,6 @@ class Artist(_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.""" @@ -1847,9 +1818,7 @@ 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, params, cacheable, stream=stream) def get_top_tracks(self, limit=None, cacheable=True, stream=False): """Returns a list of the most played Tracks by this artist.""" @@ -1857,9 +1826,7 @@ 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, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the artist page on the network. @@ -1933,9 +1900,7 @@ 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, params, cacheable, stream=stream) def get_url(self, domain_name=DOMAIN_ENGLISH): """Returns the URL of the country page on the network. @@ -2064,9 +2029,7 @@ 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, params, cacheable, stream=stream) def get_top_artists(self, limit=None, cacheable=True): """Returns a sequence of the most played artists.""" @@ -2273,37 +2236,6 @@ class User(_Chartable): return self.name - 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, - 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 - - 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) - - return _get_artist_tracks() if stream else list(_get_artist_tracks()) - def get_friends(self, limit=50, cacheable=False, stream=False): """Returns a list of the user's friends. """ @@ -2590,9 +2522,7 @@ 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, params, cacheable, stream=stream) def get_track_scrobbles(self, artist, track, cacheable=False, stream=False): """ @@ -2968,8 +2898,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 e389010..4e8d694 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 @@ -94,42 +95,29 @@ 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 + assert len(things) == test_limit - def test_artist_top_albums_limit_50(self): + def test_artist_top_albums_limit_default(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) + things = artist.get_top_albums() # 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 = list(artist.get_top_albums(limit=limit)) - - # Assert - assert len(things) == 100 - def test_artist_listener_count(self): # Arrange artist = self.network.get_artist("Test Artist") @@ -153,11 +141,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 +156,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 +171,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 +189,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 @@ -256,16 +228,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() @@ -308,4 +276,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_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 cebc7c6..b45fafa 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 @@ -63,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() diff --git a/tests/test_pylast.py b/tests/test_pylast.py index da5d816..4c39f33 100755 --- a/tests/test_pylast.py +++ b/tests/test_pylast.py @@ -6,14 +6,15 @@ import os import sys import time -import pylast import pytest from flaky import flaky +import pylast + 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 @@ -33,14 +34,19 @@ def load_secrets(): 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) -@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 diff --git a/tests/test_track.py b/tests/test_track.py index fe8eb83..b56c018 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 2b1d8fc..5f68262 100755 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -7,10 +7,10 @@ import datetime as dt import inspect import os import re -import warnings + +import pytest import pylast -import pytest from .test_pylast import TestPyLastWithLastFm @@ -69,7 +69,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 @@ -193,8 +193,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 @@ -487,15 +492,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") 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=("", "", ""))) diff --git a/tox.ini b/tox.ini index 87367e0..c19e202 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-report term-missing --random-order {posargs} +passenv = + 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 --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