Merge remote-tracking branch 'upstream/master' into streaming
This commit is contained in:
commit
10107a04e4
6
.github/labels.yml
vendored
6
.github/labels.yml
vendored
|
@ -97,6 +97,12 @@
|
||||||
- color: 0366d6
|
- color: 0366d6
|
||||||
description: "For dependencies"
|
description: "For dependencies"
|
||||||
name: dependencies
|
name: dependencies
|
||||||
|
- color: f4660e
|
||||||
|
description: ""
|
||||||
|
name: Hacktoberfest
|
||||||
|
- color: f4660e
|
||||||
|
description: "To credit accepted Hacktoberfest PRs"
|
||||||
|
name: hacktoberfest-accepted
|
||||||
- color: fef2c0
|
- color: fef2c0
|
||||||
description: ""
|
description: ""
|
||||||
name: test
|
name: test
|
||||||
|
|
21
.github/release-drafter.yml
vendored
21
.github/release-drafter.yml
vendored
|
@ -1,5 +1,5 @@
|
||||||
name-template: "$NEXT_PATCH_VERSION"
|
name-template: "Release $RESOLVED_VERSION"
|
||||||
tag-template: "$NEXT_PATCH_VERSION"
|
tag-template: "$RESOLVED_VERSION"
|
||||||
|
|
||||||
categories:
|
categories:
|
||||||
- title: "Added"
|
- title: "Added"
|
||||||
|
@ -26,3 +26,20 @@ template: |
|
||||||
## Changes
|
## Changes
|
||||||
|
|
||||||
$CHANGES
|
$CHANGES
|
||||||
|
|
||||||
|
version-resolver:
|
||||||
|
major:
|
||||||
|
labels:
|
||||||
|
- "changelog: Removed"
|
||||||
|
minor:
|
||||||
|
labels:
|
||||||
|
- "changelog: Added"
|
||||||
|
- "changelog: Changed"
|
||||||
|
- "changelog: Deprecated"
|
||||||
|
- "enhancement"
|
||||||
|
|
||||||
|
patch:
|
||||||
|
labels:
|
||||||
|
- "changelog: Fixed"
|
||||||
|
- "bug"
|
||||||
|
default: minor
|
||||||
|
|
57
.github/workflows/deploy.yml
vendored
Normal file
57
.github/workflows/deploy.yml
vendored
Normal file
|
@ -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/
|
2
.github/workflows/labels.yml
vendored
2
.github/workflows/labels.yml
vendored
|
@ -11,5 +11,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: micnncim/action-label-syncer@v1
|
- uses: micnncim/action-label-syncer@v1
|
||||||
|
with:
|
||||||
|
prune: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
31
.github/workflows/lint.yml
vendored
31
.github/workflows/lint.yml
vendored
|
@ -4,34 +4,9 @@ on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
- name: Cache
|
- uses: pre-commit/action@v2.0.0
|
||||||
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
|
|
||||||
|
|
63
.github/workflows/test.yml
vendored
Normal file
63
.github/workflows/test.yml
vendored
Normal file
|
@ -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 }}
|
|
@ -3,8 +3,6 @@ pull_request_rules:
|
||||||
conditions:
|
conditions:
|
||||||
- label=automerge
|
- label=automerge
|
||||||
- status-success=build
|
- status-success=build
|
||||||
- status-success=continuous-integration/travis-ci/pr
|
|
||||||
- status-success=continuous-integration/travis-ci/push
|
|
||||||
actions:
|
actions:
|
||||||
merge:
|
merge:
|
||||||
method: merge
|
method: merge
|
||||||
|
|
|
@ -1,42 +1,42 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.6.1
|
rev: v2.7.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: ["--py3-plus"]
|
args: ["--py36-plus"]
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 19.10b0
|
rev: 20.8b1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args: ["--target-version", "py35"]
|
args: ["--target-version", "py36"]
|
||||||
# override until resolved: https://github.com/psf/black/issues/402
|
# override until resolved: https://github.com/psf/black/issues/402
|
||||||
files: \.pyi?$
|
files: \.pyi?$
|
||||||
types: []
|
types: []
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/isort
|
||||||
|
rev: 5.6.4
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
- repo: https://gitlab.com/pycqa/flake8
|
- repo: https://gitlab.com/pycqa/flake8
|
||||||
rev: 3.8.3
|
rev: 3.8.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
additional_dependencies: [flake8-2020, flake8-implicit-str-concat]
|
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
|
- repo: https://github.com/pre-commit/pygrep-hooks
|
||||||
rev: v1.5.1
|
rev: v1.7.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: python-check-blanket-noqa
|
- id: python-check-blanket-noqa
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v3.1.0
|
rev: v3.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
|
||||||
|
- repo: https://github.com/tox-dev/tox-ini-fmt
|
||||||
|
rev: 0.5.0
|
||||||
|
hooks:
|
||||||
|
- id: tox-ini-fmt
|
||||||
|
|
67
.travis.yml
67
.travis.yml
|
@ -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
|
|
22
CHANGELOG.md
22
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/),
|
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).
|
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
|
## [3.3.0] - 2020-06-25
|
||||||
### Added
|
### 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])
|
* Support for Python 2.7 ([#265])
|
||||||
|
|
||||||
[3.3.0]: https://github.com/pylast/pylast/compare/v3.2.1...3.3.0
|
[4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0
|
||||||
[3.2.1]: https://github.com/pylast/pylast/compare/v3.2.0...3.2.1
|
[3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0
|
||||||
[3.2.0]: https://github.com/pylast/pylast/compare/v3.1.0...3.2.0
|
[3.2.1]: https://github.com/pylast/pylast/compare/3.2.0...3.2.1
|
||||||
[3.1.0]: https://github.com/pylast/pylast/compare/v3.0.0...3.1.0
|
[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
|
[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
|
[2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0
|
||||||
[#265]: https://github.com/pylast/pylast/issues/265
|
[#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
|
[#311]: https://github.com/pylast/pylast/issues/311
|
||||||
[#312]: https://github.com/pylast/pylast/issues/312
|
[#312]: https://github.com/pylast/pylast/issues/312
|
||||||
[#316]: https://github.com/pylast/pylast/issues/316
|
[#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
|
||||||
|
|
18
README.md
18
README.md
|
@ -4,10 +4,9 @@ pyLast
|
||||||
[](https://pypi.org/project/pylast/)
|
[](https://pypi.org/project/pylast/)
|
||||||
[](https://pypi.org/project/pylast/)
|
[](https://pypi.org/project/pylast/)
|
||||||
[](https://pypistats.org/packages/pylast)
|
[](https://pypistats.org/packages/pylast)
|
||||||
[](https://travis-ci.org/pylast/pylast)
|
[](https://github.com/pylast/pylast/actions)
|
||||||
[](https://codecov.io/gh/pylast/pylast)
|
[](https://codecov.io/gh/pylast/pylast)
|
||||||
[](https://coveralls.io/github/pylast/pylast?branch=master)
|
[](https://github.com/psf/black)
|
||||||
[](https://github.com/python/black)
|
|
||||||
[](https://zenodo.org/badge/latestdoi/7803088)
|
[](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/).
|
||||||
|
@ -31,11 +30,13 @@ Or from requirements.txt:
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
* pylast 3.0.0+ supports Python 3.5+ ([#265](https://github.com/pylast/pylast/issues/265))
|
* pyLast 4.0.0+ supports Python 3.6-3.9.
|
||||||
* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4, 3.5, 3.6, 3.7.
|
* pyLast 3.2.0 - 3.3.0 supports Python 3.5-3.8.
|
||||||
* pyLast 2.0.0 - 2.1.0 supports Python 2.7.10+, 3.4, 3.5, 3.6.
|
* pyLast 3.0.0 - 3.1.0 supports Python 3.5-3.7.
|
||||||
* pyLast 1.7.0 - 1.9.0 supports Python 2.7, 3.3, 3.4, 3.5, 3.6.
|
* pyLast 2.2.0 - 2.4.0 supports Python 2.7.10+, 3.4-3.7.
|
||||||
* pyLast 1.0.0 - 1.6.0 supports Python 2.7, 3.3, 3.4.
|
* 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, 3.
|
||||||
* pyLast < 0.5 supports Python 2.
|
* pyLast < 0.5 supports Python 2.
|
||||||
|
|
||||||
|
@ -49,7 +50,6 @@ Features
|
||||||
* Proxy support.
|
* Proxy support.
|
||||||
* Internal caching support for some web services calls (disabled by default).
|
* Internal caching support for some web services calls (disabled by default).
|
||||||
* Support for other API-compatible networks like Libre.fm.
|
* Support for other API-compatible networks like Libre.fm.
|
||||||
* Python 3-friendly (Starting from 0.5).
|
|
||||||
|
|
||||||
|
|
||||||
Getting started
|
Getting started
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# Release Checklist
|
# Release Checklist
|
||||||
|
|
||||||
* [ ] Get master to the appropriate code release state.
|
* [ ] 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.
|
all merges to master.
|
||||||
|
[](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
|
https://github.com/pylast/pylast/releases
|
||||||
|
@ -13,8 +14,8 @@
|
||||||
|
|
||||||
* [ ] Publish release
|
* [ ] Publish release
|
||||||
|
|
||||||
* [ ] Check the tagged [Travis CI build](https://travis-ci.org/pylast/pylast) has
|
* [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy)
|
||||||
deployed to [PyPI](https://pypi.org/project/pylast/#history)
|
has deployed to [PyPI](https://pypi.org/project/pylast/#history)
|
||||||
|
|
||||||
* [ ] Check installation:
|
* [ ] Check installation:
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
[flake8]
|
[flake8]
|
||||||
ignore = W503
|
|
||||||
max_line_length = 88
|
max_line_length = 88
|
||||||
|
|
||||||
[tool:isort]
|
[tool:isort]
|
||||||
known_third_party = flaky,pkg_resources,pylast,pytest,setuptools
|
profile = black
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -27,7 +27,7 @@ setup(
|
||||||
extras_require={
|
extras_require={
|
||||||
"tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
|
"tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
|
||||||
},
|
},
|
||||||
python_requires=">=3.5",
|
python_requires=">=3.6",
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
@ -35,10 +35,10 @@ setup(
|
||||||
"Topic :: Multimedia :: Sound/Audio",
|
"Topic :: Multimedia :: Sound/Audio",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.5",
|
|
||||||
"Programming Language :: Python :: 3.6",
|
"Programming Language :: Python :: 3.6",
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
|
|
|
@ -27,7 +27,6 @@ import shelve
|
||||||
import ssl
|
import ssl
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import warnings
|
|
||||||
import xml.dom
|
import xml.dom
|
||||||
from http.client import HTTPSConnection
|
from http.client import HTTPSConnection
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
@ -49,9 +48,7 @@ STATUS_AUTH_FAILED = 4
|
||||||
STATUS_INVALID_FORMAT = 5
|
STATUS_INVALID_FORMAT = 5
|
||||||
STATUS_INVALID_PARAMS = 6
|
STATUS_INVALID_PARAMS = 6
|
||||||
STATUS_INVALID_RESOURCE = 7
|
STATUS_INVALID_RESOURCE = 7
|
||||||
# DeprecationWarning: STATUS_TOKEN_ERROR is deprecated and will be
|
STATUS_OPERATION_FAILED = 8
|
||||||
# removed in a future version. Use STATUS_OPERATION_FAILED instead.
|
|
||||||
STATUS_OPERATION_FAILED = STATUS_TOKEN_ERROR = 8
|
|
||||||
STATUS_INVALID_SK = 9
|
STATUS_INVALID_SK = 9
|
||||||
STATUS_INVALID_API_KEY = 10
|
STATUS_INVALID_API_KEY = 10
|
||||||
STATUS_OFFLINE = 11
|
STATUS_OFFLINE = 11
|
||||||
|
@ -146,29 +143,29 @@ class _Network:
|
||||||
token=None,
|
token=None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
name: the name of the network
|
name: the name of the network
|
||||||
homepage: the homepage URL
|
homepage: the homepage URL
|
||||||
ws_server: the URL of the webservices server
|
ws_server: the URL of the webservices server
|
||||||
api_key: a provided API_KEY
|
api_key: a provided API_KEY
|
||||||
api_secret: a provided API_SECRET
|
api_secret: a provided API_SECRET
|
||||||
session_key: a generated session_key or None
|
session_key: a generated session_key or None
|
||||||
username: a username of a valid user
|
username: a username of a valid user
|
||||||
password_hash: the output of pylast.md5(password) where password is
|
password_hash: the output of pylast.md5(password) where password is
|
||||||
the user's password
|
the user's password
|
||||||
domain_names: a dict mapping each DOMAIN_* value to a string domain
|
domain_names: a dict mapping each DOMAIN_* value to a string domain
|
||||||
name
|
name
|
||||||
urls: a dict mapping types to URLs
|
urls: a dict mapping types to URLs
|
||||||
token: an authentication token to retrieve a session
|
token: an authentication token to retrieve a session
|
||||||
|
|
||||||
if username and password_hash were provided and not session_key,
|
if username and password_hash were provided and not session_key,
|
||||||
session_key will be generated automatically when needed.
|
session_key will be generated automatically when needed.
|
||||||
|
|
||||||
Either a valid session_key or a combination of username and
|
Either a valid session_key or a combination of username and
|
||||||
password_hash must be present for scrobbling.
|
password_hash must be present for scrobbling.
|
||||||
|
|
||||||
You should use a preconfigured network object through a
|
You should use a preconfigured network object through a
|
||||||
get_*_network(...) method instead of creating an object
|
get_*_network(...) method instead of creating an object
|
||||||
of this class, unless you know what you're doing.
|
of this class, unless you know what you're doing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
@ -209,56 +206,56 @@ class _Network:
|
||||||
|
|
||||||
def get_artist(self, artist_name):
|
def get_artist(self, artist_name):
|
||||||
"""
|
"""
|
||||||
Return an Artist object
|
Return an Artist object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Artist(artist_name, self)
|
return Artist(artist_name, self)
|
||||||
|
|
||||||
def get_track(self, artist, title):
|
def get_track(self, artist, title):
|
||||||
"""
|
"""
|
||||||
Return a Track object
|
Return a Track object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Track(artist, title, self)
|
return Track(artist, title, self)
|
||||||
|
|
||||||
def get_album(self, artist, title):
|
def get_album(self, artist, title):
|
||||||
"""
|
"""
|
||||||
Return an Album object
|
Return an Album object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Album(artist, title, self)
|
return Album(artist, title, self)
|
||||||
|
|
||||||
def get_authenticated_user(self):
|
def get_authenticated_user(self):
|
||||||
"""
|
"""
|
||||||
Returns the authenticated user
|
Returns the authenticated user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return AuthenticatedUser(self)
|
return AuthenticatedUser(self)
|
||||||
|
|
||||||
def get_country(self, country_name):
|
def get_country(self, country_name):
|
||||||
"""
|
"""
|
||||||
Returns a country object
|
Returns a country object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Country(country_name, self)
|
return Country(country_name, self)
|
||||||
|
|
||||||
def get_user(self, username):
|
def get_user(self, username):
|
||||||
"""
|
"""
|
||||||
Returns a user object
|
Returns a user object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return User(username, self)
|
return User(username, self)
|
||||||
|
|
||||||
def get_tag(self, name):
|
def get_tag(self, name):
|
||||||
"""
|
"""
|
||||||
Returns a tag object
|
Returns a tag object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Tag(name, self)
|
return Tag(name, self)
|
||||||
|
|
||||||
def _get_language_domain(self, domain_language):
|
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:
|
if domain_language in self.domain_names:
|
||||||
|
@ -271,13 +268,13 @@ class _Network:
|
||||||
|
|
||||||
def _get_ws_auth(self):
|
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
|
return self.api_key, self.api_secret, self.session_key
|
||||||
|
|
||||||
def _delay_call(self):
|
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()
|
now = time.time()
|
||||||
|
|
||||||
|
@ -1150,12 +1147,12 @@ class _BaseObject:
|
||||||
return first_child.wholeText.strip()
|
return first_child.wholeText.strip()
|
||||||
|
|
||||||
def _get_things(
|
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."""
|
"""Returns a list of the most played thing_types by this thing."""
|
||||||
|
|
||||||
def _stream_get_things():
|
def _stream_get_things():
|
||||||
limit = params.get("limit", 1)
|
limit = params.get("limit", 50)
|
||||||
nodes = _collect_nodes(
|
nodes = _collect_nodes(
|
||||||
limit,
|
limit,
|
||||||
self,
|
self,
|
||||||
|
@ -1416,31 +1413,31 @@ class WSError(Exception):
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
"""Returns the exception ID, from one of the following:
|
"""Returns the exception ID, from one of the following:
|
||||||
STATUS_INVALID_SERVICE = 2
|
STATUS_INVALID_SERVICE = 2
|
||||||
STATUS_INVALID_METHOD = 3
|
STATUS_INVALID_METHOD = 3
|
||||||
STATUS_AUTH_FAILED = 4
|
STATUS_AUTH_FAILED = 4
|
||||||
STATUS_INVALID_FORMAT = 5
|
STATUS_INVALID_FORMAT = 5
|
||||||
STATUS_INVALID_PARAMS = 6
|
STATUS_INVALID_PARAMS = 6
|
||||||
STATUS_INVALID_RESOURCE = 7
|
STATUS_INVALID_RESOURCE = 7
|
||||||
STATUS_OPERATION_FAILED = 8
|
STATUS_OPERATION_FAILED = 8
|
||||||
STATUS_INVALID_SK = 9
|
STATUS_INVALID_SK = 9
|
||||||
STATUS_INVALID_API_KEY = 10
|
STATUS_INVALID_API_KEY = 10
|
||||||
STATUS_OFFLINE = 11
|
STATUS_OFFLINE = 11
|
||||||
STATUS_SUBSCRIBERS_ONLY = 12
|
STATUS_SUBSCRIBERS_ONLY = 12
|
||||||
STATUS_TOKEN_UNAUTHORIZED = 14
|
STATUS_TOKEN_UNAUTHORIZED = 14
|
||||||
STATUS_TOKEN_EXPIRED = 15
|
STATUS_TOKEN_EXPIRED = 15
|
||||||
STATUS_TEMPORARILY_UNAVAILABLE = 16
|
STATUS_TEMPORARILY_UNAVAILABLE = 16
|
||||||
STATUS_LOGIN_REQUIRED = 17
|
STATUS_LOGIN_REQUIRED = 17
|
||||||
STATUS_TRIAL_EXPIRED = 18
|
STATUS_TRIAL_EXPIRED = 18
|
||||||
STATUS_NOT_ENOUGH_CONTENT = 20
|
STATUS_NOT_ENOUGH_CONTENT = 20
|
||||||
STATUS_NOT_ENOUGH_MEMBERS = 21
|
STATUS_NOT_ENOUGH_MEMBERS = 21
|
||||||
STATUS_NOT_ENOUGH_FANS = 22
|
STATUS_NOT_ENOUGH_FANS = 22
|
||||||
STATUS_NOT_ENOUGH_NEIGHBOURS = 23
|
STATUS_NOT_ENOUGH_NEIGHBOURS = 23
|
||||||
STATUS_NO_PEAK_RADIO = 24
|
STATUS_NO_PEAK_RADIO = 24
|
||||||
STATUS_RADIO_NOT_FOUND = 25
|
STATUS_RADIO_NOT_FOUND = 25
|
||||||
STATUS_API_KEY_SUSPENDED = 26
|
STATUS_API_KEY_SUSPENDED = 26
|
||||||
STATUS_DEPRECATED = 27
|
STATUS_DEPRECATED = 27
|
||||||
STATUS_RATE_LIMIT_EXCEEDED = 29
|
STATUS_RATE_LIMIT_EXCEEDED = 29
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return self.status
|
return self.status
|
||||||
|
@ -1721,32 +1718,6 @@ class Artist(_Taggable):
|
||||||
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
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):
|
def get_playcount(self):
|
||||||
"""Returns the number of plays on the network."""
|
"""Returns the number of plays on the network."""
|
||||||
|
|
||||||
|
@ -1847,9 +1818,7 @@ class Artist(_Taggable):
|
||||||
if limit:
|
if limit:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
|
|
||||||
return self._get_things(
|
return self._get_things("getTopAlbums", Album, params, cacheable, stream=stream)
|
||||||
"getTopAlbums", "album", Album, params, cacheable, stream=stream
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_top_tracks(self, limit=None, cacheable=True, stream=False):
|
def get_top_tracks(self, limit=None, cacheable=True, stream=False):
|
||||||
"""Returns a list of the most played Tracks by this artist."""
|
"""Returns a list of the most played Tracks by this artist."""
|
||||||
|
@ -1857,9 +1826,7 @@ class Artist(_Taggable):
|
||||||
if limit:
|
if limit:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
|
|
||||||
return self._get_things(
|
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
|
||||||
"getTopTracks", "track", Track, params, cacheable, stream=stream
|
|
||||||
)
|
|
||||||
|
|
||||||
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.
|
"""Returns the URL of the artist page on the network.
|
||||||
|
@ -1933,9 +1900,7 @@ class Country(_BaseObject):
|
||||||
if limit:
|
if limit:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
|
|
||||||
return self._get_things(
|
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
|
||||||
"getTopTracks", "track", Track, params, cacheable, stream=stream
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_url(self, domain_name=DOMAIN_ENGLISH):
|
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.
|
||||||
|
@ -2064,9 +2029,7 @@ class Tag(_Chartable):
|
||||||
if limit:
|
if limit:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
|
|
||||||
return self._get_things(
|
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
|
||||||
"getTopTracks", "track", Track, params, cacheable, stream=stream
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_top_artists(self, limit=None, cacheable=True):
|
def get_top_artists(self, limit=None, cacheable=True):
|
||||||
"""Returns a sequence of the most played artists."""
|
"""Returns a sequence of the most played artists."""
|
||||||
|
@ -2273,37 +2236,6 @@ class User(_Chartable):
|
||||||
|
|
||||||
return self.name
|
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):
|
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. """
|
||||||
|
|
||||||
|
@ -2590,9 +2522,7 @@ class User(_Chartable):
|
||||||
if limit:
|
if limit:
|
||||||
params["limit"] = limit
|
params["limit"] = limit
|
||||||
|
|
||||||
return self._get_things(
|
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
|
||||||
"getTopTracks", "track", Track, params, cacheable, stream=stream
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_track_scrobbles(self, artist, track, cacheable=False, stream=False):
|
def get_track_scrobbles(self, artist, track, cacheable=False, stream=False):
|
||||||
"""
|
"""
|
||||||
|
@ -2968,8 +2898,8 @@ def _url_safe(text):
|
||||||
|
|
||||||
def _number(string):
|
def _number(string):
|
||||||
"""
|
"""
|
||||||
Extracts an int from a string.
|
Extracts an int from a string.
|
||||||
Returns a 0 if None or an empty string was passed.
|
Returns a 0 if None or an empty string was passed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not string:
|
if not string:
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
import pylast
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
||||||
|
|
||||||
|
|
||||||
|
@ -94,42 +95,29 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
self.helper_two_different_things_in_top_list(things, pylast.Album)
|
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
|
# Arrange
|
||||||
limit = 1
|
|
||||||
# Pick an artist with plenty of plays
|
# Pick an artist with plenty of plays
|
||||||
artist = self.network.get_top_artists(limit=1)[0].item
|
artist = self.network.get_top_artists(limit=1)[0].item
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
things = artist.get_top_albums(limit=limit)
|
things = artist.get_top_albums(limit=test_limit)
|
||||||
|
|
||||||
# Assert
|
# 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
|
# Arrange
|
||||||
limit = 50
|
|
||||||
# Pick an artist with plenty of plays
|
# Pick an artist with plenty of plays
|
||||||
artist = self.network.get_top_artists(limit=1)[0].item
|
artist = self.network.get_top_artists(limit=1)[0].item
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
things = artist.get_top_albums(limit=limit)
|
things = artist.get_top_albums()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert len(things) == 50
|
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):
|
def test_artist_listener_count(self):
|
||||||
# Arrange
|
# Arrange
|
||||||
artist = self.network.get_artist("Test Artist")
|
artist = self.network.get_artist("Test Artist")
|
||||||
|
@ -153,11 +141,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
tags = artist.get_tags()
|
tags = artist.get_tags()
|
||||||
assert len(tags) > 0
|
assert len(tags) > 0
|
||||||
found = False
|
found = any(tag.name == "testing" for tag in tags)
|
||||||
for tag in tags:
|
|
||||||
if tag.name == "testing":
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
assert found
|
assert found
|
||||||
|
|
||||||
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
||||||
|
@ -172,11 +156,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
tags = artist.get_tags()
|
tags = artist.get_tags()
|
||||||
found = False
|
found = any(tag.name == "testing" for tag in tags)
|
||||||
for tag in tags:
|
|
||||||
if tag.name == "testing":
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
assert not found
|
assert not found
|
||||||
|
|
||||||
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
||||||
|
@ -191,11 +171,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
tags = artist.get_tags()
|
tags = artist.get_tags()
|
||||||
found = False
|
found = any(tag.name == "testing" for tag in tags)
|
||||||
for tag in tags:
|
|
||||||
if tag.name == "testing":
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
assert not found
|
assert not found
|
||||||
|
|
||||||
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
|
||||||
|
@ -213,12 +189,8 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
tags_after = artist.get_tags()
|
tags_after = artist.get_tags()
|
||||||
assert len(tags_after) == len(tags_before) - 2
|
assert len(tags_after) == len(tags_before) - 2
|
||||||
found1, found2 = False, False
|
found1 = any(tag.name == "removetag1" for tag in tags_after)
|
||||||
for tag in tags_after:
|
found2 = any(tag.name == "removetag2" for tag in tags_after)
|
||||||
if tag.name == "removetag1":
|
|
||||||
found1 = True
|
|
||||||
elif tag.name == "removetag2":
|
|
||||||
found2 = True
|
|
||||||
assert not found1
|
assert not found1
|
||||||
assert not found2
|
assert not found2
|
||||||
|
|
||||||
|
@ -256,16 +228,12 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
url = artist1.get_url()
|
url = artist1.get_url()
|
||||||
mbid = artist1.get_mbid()
|
mbid = artist1.get_mbid()
|
||||||
|
|
||||||
with pytest.warns(DeprecationWarning):
|
|
||||||
image = artist1.get_cover_image()
|
|
||||||
|
|
||||||
playcount = artist1.get_playcount()
|
playcount = artist1.get_playcount()
|
||||||
streamable = artist1.is_streamable()
|
streamable = artist1.is_streamable()
|
||||||
name = artist1.get_name(properly_capitalized=False)
|
name = artist1.get_name(properly_capitalized=False)
|
||||||
name_cap = artist1.get_name(properly_capitalized=True)
|
name_cap = artist1.get_name(properly_capitalized=True)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert "https" in image
|
|
||||||
assert playcount > 1
|
assert playcount > 1
|
||||||
assert artist1 != artist2
|
assert artist1 != artist2
|
||||||
assert name.lower() == name_cap.lower()
|
assert name.lower() == name_cap.lower()
|
||||||
|
@ -308,4 +276,4 @@ class TestPyLastArtist(TestPyLastWithLastFm):
|
||||||
playcount = artist.get_userplaycount()
|
playcount = artist.get_userplaycount()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert playcount >= 0
|
assert playcount >= 0 # whilst xfail: # pragma: no cover
|
||||||
|
|
|
@ -2,9 +2,10 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
import pylast
|
|
||||||
from flaky import flaky
|
from flaky import flaky
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import PyLastTestCase, load_secrets
|
from .test_pylast import PyLastTestCase, load_secrets
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,10 @@ Integration (not unit) tests for pylast.py
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
||||||
|
|
||||||
|
|
||||||
|
@ -63,7 +64,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
|
||||||
self.network.enable_rate_limit()
|
self.network.enable_rate_limit()
|
||||||
then = time.time()
|
then = time.time()
|
||||||
# Make some network call, limit not applied first 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
|
# Make a second network call, limiting should be applied
|
||||||
self.network.get_top_artists()
|
self.network.get_top_artists()
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
|
@ -6,14 +6,15 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytest
|
import pytest
|
||||||
from flaky import flaky
|
from flaky import flaky
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
WRITE_TEST = sys.version_info[:2] == (3, 8)
|
WRITE_TEST = sys.version_info[:2] == (3, 8)
|
||||||
|
|
||||||
|
|
||||||
def load_secrets():
|
def load_secrets(): # pragma: no cover
|
||||||
secrets_file = "test_pylast.yaml"
|
secrets_file = "test_pylast.yaml"
|
||||||
if os.path.isfile(secrets_file):
|
if os.path.isfile(secrets_file):
|
||||||
import yaml # pip install pyyaml
|
import yaml # pip install pyyaml
|
||||||
|
@ -33,14 +34,19 @@ def load_secrets():
|
||||||
|
|
||||||
|
|
||||||
class PyLastTestCase:
|
class PyLastTestCase:
|
||||||
def assert_startswith(self, str, prefix, start=None, end=None):
|
def assert_startswith(self, s, prefix, start=None, end=None):
|
||||||
assert str.startswith(prefix, start, end)
|
assert s.startswith(prefix, start, end)
|
||||||
|
|
||||||
def assert_endswith(self, str, suffix, start=None, end=None):
|
def assert_endswith(self, s, suffix, start=None, end=None):
|
||||||
assert str.endswith(suffix, start, end)
|
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):
|
class TestPyLastWithLastFm(PyLastTestCase):
|
||||||
|
|
||||||
secrets = None
|
secrets = None
|
||||||
|
|
|
@ -4,9 +4,10 @@ Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,10 +7,10 @@ import datetime as dt
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import warnings
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .test_pylast import TestPyLastWithLastFm
|
from .test_pylast import TestPyLastWithLastFm
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
if int(registered):
|
if int(registered):
|
||||||
# Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp
|
# Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp
|
||||||
assert registered == "1037793040"
|
assert registered == "1037793040"
|
||||||
else:
|
else: # pragma: no cover
|
||||||
# Old way
|
# Old way
|
||||||
# Just check date because of timezones
|
# Just check date because of timezones
|
||||||
assert "2002-11-20 " in registered
|
assert "2002-11-20 " in registered
|
||||||
|
@ -193,8 +193,13 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
|
|
||||||
# Act/Assert
|
# Act/Assert
|
||||||
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")
|
# no cover whilst xfail:
|
||||||
self.helper_validate_cacheable(lastfm_user, "get_recent_tracks")
|
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):
|
def test_user_get_top_tags_with_limit(self):
|
||||||
# Arrange
|
# Arrange
|
||||||
|
@ -487,15 +492,3 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
self.helper_validate_results(result1, result2, result3)
|
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")
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pylast
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
|
|
||||||
def mock_network():
|
def mock_network():
|
||||||
return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", "")))
|
return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", "")))
|
||||||
|
|
34
tox.ini
34
tox.ini
|
@ -1,21 +1,29 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist = py{36, 37, 38, 39, 310, py3}
|
envlist =
|
||||||
|
py{py3, 310, 39, 38, 37, 36}
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
extras = tests
|
passenv =
|
||||||
setenv =
|
PYLAST_API_KEY
|
||||||
PYLAST_USERNAME={env:PYLAST_USERNAME:}
|
PYLAST_API_SECRET
|
||||||
PYLAST_PASSWORD_HASH={env:PYLAST_PASSWORD_HASH:}
|
PYLAST_PASSWORD_HASH
|
||||||
PYLAST_API_KEY={env:PYLAST_API_KEY:}
|
PYLAST_USERNAME
|
||||||
PYLAST_API_SECRET={env:PYLAST_API_SECRET:}
|
extras =
|
||||||
commands = pytest -v -s -W all --cov pylast --cov-report term-missing --random-order {posargs}
|
tests
|
||||||
|
commands =
|
||||||
|
pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs}
|
||||||
|
|
||||||
[testenv:venv]
|
[testenv:venv]
|
||||||
deps = ipdb
|
deps =
|
||||||
commands = {posargs}
|
ipdb
|
||||||
|
commands =
|
||||||
|
{posargs}
|
||||||
|
|
||||||
[testenv:lint]
|
[testenv:lint]
|
||||||
deps = pre-commit
|
passenv =
|
||||||
commands = pre-commit run --all-files --show-diff-on-failure
|
PRE_COMMIT_COLOR
|
||||||
skip_install = true
|
skip_install = true
|
||||||
passenv = PRE_COMMIT_COLOR
|
deps =
|
||||||
|
pre-commit
|
||||||
|
commands =
|
||||||
|
pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|
Loading…
Reference in a new issue