Compare commits
3 commits
main
...
refactor-u
Author | SHA1 | Date | |
---|---|---|---|
|
ddc80fc5c3 | ||
|
09fcc776a7 | ||
|
620323eab0 |
5
.github/release-drafter.yml
vendored
5
.github/release-drafter.yml
vendored
|
@ -22,11 +22,6 @@ categories:
|
||||||
exclude-labels:
|
exclude-labels:
|
||||||
- "changelog: skip"
|
- "changelog: skip"
|
||||||
|
|
||||||
autolabeler:
|
|
||||||
- label: "changelog: skip"
|
|
||||||
branch:
|
|
||||||
- "/pre-commit-ci-update-config/"
|
|
||||||
|
|
||||||
template: |
|
template: |
|
||||||
$CHANGES
|
$CHANGES
|
||||||
|
|
||||||
|
|
13
.github/renovate.json
vendored
13
.github/renovate.json
vendored
|
@ -1,13 +1,16 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"extends": [
|
||||||
"extends": ["config:base"],
|
"config:base"
|
||||||
"labels": ["changelog: skip", "dependencies"],
|
],
|
||||||
|
"labels": [
|
||||||
|
"changelog: skip",
|
||||||
|
"dependencies"
|
||||||
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"groupName": "github-actions",
|
"groupName": "github-actions",
|
||||||
"matchManagers": ["github-actions"],
|
"matchManagers": ["github-actions"],
|
||||||
"separateMajorMinor": "false"
|
"separateMajorMinor": "false"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"schedule": ["on the first day of the month"]
|
|
||||||
}
|
}
|
||||||
|
|
83
.github/workflows/deploy.yml
vendored
83
.github/workflows/deploy.yml
vendored
|
@ -2,74 +2,51 @@ name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
tags: ["*"]
|
- main
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
release:
|
release:
|
||||||
types:
|
types:
|
||||||
- published
|
- published
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Always build & lint package.
|
deploy:
|
||||||
build-package:
|
if: github.repository_owner == 'pylast'
|
||||||
name: Build & verify package
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: hynek/build-and-inspect-python-package@v2
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
# Upload to Test PyPI on every commit on main.
|
|
||||||
release-test-pypi:
|
|
||||||
name: Publish in-dev package to test.pypi.org
|
|
||||||
if: |
|
|
||||||
github.repository_owner == 'pylast'
|
|
||||||
&& github.event_name == 'push'
|
|
||||||
&& github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-package
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download packages built by build-and-inspect-python-package
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: Packages
|
python-version: "3.10"
|
||||||
path: dist
|
cache: pip
|
||||||
|
cache-dependency-path: setup.cfg
|
||||||
|
|
||||||
- name: Upload package to Test PyPI
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -U build twine wheel
|
||||||
|
|
||||||
|
- name: Build package
|
||||||
|
run: |
|
||||||
|
python setup.py --version
|
||||||
|
python -m build
|
||||||
|
twine check --strict dist/*
|
||||||
|
|
||||||
|
- name: Publish package to PyPI
|
||||||
|
if: github.event.action == 'published'
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
repository-url: https://test.pypi.org/legacy/
|
user: __token__
|
||||||
|
password: ${{ secrets.pypi_password }}
|
||||||
|
|
||||||
# Upload to real PyPI on GitHub Releases.
|
- name: Publish package to TestPyPI
|
||||||
release-pypi:
|
|
||||||
name: Publish released package to pypi.org
|
|
||||||
if: |
|
|
||||||
github.repository_owner == 'pylast'
|
|
||||||
&& github.event.action == 'published'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build-package
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download packages built by build-and-inspect-python-package
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: Packages
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- name: Upload package to PyPI
|
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
user: __token__
|
||||||
|
password: ${{ secrets.test_pypi_password }}
|
||||||
|
repository_url: https://test.pypi.org/legacy/
|
||||||
|
|
5
.github/workflows/labels.yml
vendored
5
.github/workflows/labels.yml
vendored
|
@ -1,8 +1,5 @@
|
||||||
name: Sync labels
|
name: Sync labels
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
@ -15,7 +12,7 @@ jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: micnncim/action-label-syncer@v1
|
- uses: micnncim/action-label-syncer@v1
|
||||||
with:
|
with:
|
||||||
prune: false
|
prune: false
|
||||||
|
|
14
.github/workflows/lint.yml
vendored
14
.github/workflows/lint.yml
vendored
|
@ -2,21 +2,13 @@ name: Lint
|
||||||
|
|
||||||
on: [push, pull_request, workflow_dispatch]
|
on: [push, pull_request, workflow_dispatch]
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_COLOR: 1
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK: 1
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
cache: pip
|
- uses: pre-commit/action@v3.0.0
|
||||||
- uses: pre-commit/action@v3.0.1
|
|
||||||
|
|
18
.github/workflows/release-drafter.yml
vendored
18
.github/workflows/release-drafter.yml
vendored
|
@ -5,30 +5,14 @@ on:
|
||||||
# branches to consider in the event; optional, defaults to all
|
# branches to consider in the event; optional, defaults to all
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
# pull_request event is required only for autolabeler
|
|
||||||
pull_request:
|
|
||||||
# Only following types are handled by the action, but one can default to all as well
|
|
||||||
types: [opened, reopened, synchronize]
|
|
||||||
# pull_request_target event is required for autolabeler to support PRs from forks
|
|
||||||
# pull_request_target:
|
|
||||||
# types: [opened, reopened, synchronize]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update_release_draft:
|
update_release_draft:
|
||||||
if: github.repository_owner == 'pylast'
|
if: github.repository_owner == 'pylast'
|
||||||
permissions:
|
|
||||||
# write permission is required to create a GitHub Release
|
|
||||||
contents: write
|
|
||||||
# write permission is required for autolabeler
|
|
||||||
# otherwise, read permission is required at least
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Drafts your next release notes as pull requests are merged into "main"
|
# Drafts your next release notes as pull requests are merged into "main"
|
||||||
- uses: release-drafter/release-drafter@v6
|
- uses: release-drafter/release-drafter@v5
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
6
.github/workflows/require-pr-label.yml
vendored
6
.github/workflows/require-pr-label.yml
vendored
|
@ -8,12 +8,8 @@ jobs:
|
||||||
label:
|
label:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: mheap/github-action-required-labels@v5
|
- uses: mheap/github-action-required-labels@v2
|
||||||
with:
|
with:
|
||||||
mode: minimum
|
mode: minimum
|
||||||
count: 1
|
count: 1
|
||||||
|
|
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
|
@ -11,18 +11,18 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11-dev"]
|
||||||
os: [ubuntu-latest]
|
os: [ubuntu-latest]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
allow-prereleases: true
|
|
||||||
cache: pip
|
cache: pip
|
||||||
|
cache-dependency-path: setup.cfg
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
@ -40,7 +40,7 @@ jobs:
|
||||||
PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }}
|
PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }}
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v3.1.5
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
flags: ${{ matrix.os }}
|
flags: ${{ matrix.os }}
|
||||||
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
||||||
|
@ -48,7 +48,7 @@ jobs:
|
||||||
success:
|
success:
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Test successful
|
name: test successful
|
||||||
steps:
|
steps:
|
||||||
- name: Success
|
- name: Success
|
||||||
run: echo Test successful
|
run: echo Test successful
|
||||||
|
|
|
@ -1,74 +1,56 @@
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v0.5.0
|
rev: v2.34.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: pyupgrade
|
||||||
args: [--exit-non-zero-on-fix]
|
args: [--py37-plus]
|
||||||
|
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black
|
||||||
rev: 24.4.2
|
rev: 22.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
|
args: [--target-version=py37]
|
||||||
|
|
||||||
- repo: https://github.com/asottile/blacken-docs
|
- repo: https://github.com/asottile/blacken-docs
|
||||||
rev: 1.18.0
|
rev: v1.12.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: blacken-docs
|
- id: blacken-docs
|
||||||
args: [--target-version=py38]
|
args: [--target-version=py37]
|
||||||
additional_dependencies: [black]
|
additional_dependencies: [black==21.12b0]
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/isort
|
||||||
|
rev: 5.10.1
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: 4.0.1
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
additional_dependencies: [flake8-2020, flake8-implicit-str-concat]
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pygrep-hooks
|
||||||
|
rev: v1.9.0
|
||||||
|
hooks:
|
||||||
|
- id: python-check-blanket-noqa
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.6.0
|
rev: v4.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-added-large-files
|
|
||||||
- id: check-case-conflict
|
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: debug-statements
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: forbid-submodules
|
|
||||||
- id: trailing-whitespace
|
|
||||||
exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md
|
|
||||||
|
|
||||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||||
rev: 0.28.6
|
rev: v1.20.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-github-workflows
|
- id: setup-cfg-fmt
|
||||||
- id: check-renovate
|
args: [--max-py-version=3.11]
|
||||||
|
|
||||||
- repo: https://github.com/rhysd/actionlint
|
|
||||||
rev: v1.7.1
|
|
||||||
hooks:
|
|
||||||
- id: actionlint
|
|
||||||
|
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
||||||
rev: 2.1.3
|
|
||||||
hooks:
|
|
||||||
- id: pyproject-fmt
|
|
||||||
|
|
||||||
- repo: https://github.com/abravalheri/validate-pyproject
|
|
||||||
rev: v0.18
|
|
||||||
hooks:
|
|
||||||
- id: validate-pyproject
|
|
||||||
|
|
||||||
- repo: https://github.com/tox-dev/tox-ini-fmt
|
- repo: https://github.com/tox-dev/tox-ini-fmt
|
||||||
rev: 1.3.1
|
rev: 0.5.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: tox-ini-fmt
|
- id: tox-ini-fmt
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v4.0.0-alpha.8
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
args: [--prose-wrap=always, --print-width=88]
|
|
||||||
exclude: .github/(ISSUE_TEMPLATE|PULL_REQUEST_TEMPLATE).md
|
|
||||||
|
|
||||||
- repo: meta
|
|
||||||
hooks:
|
|
||||||
- id: check-hooks-apply
|
|
||||||
- id: check-useless-excludes
|
|
||||||
|
|
||||||
ci:
|
ci:
|
||||||
autoupdate_schedule: quarterly
|
autoupdate_schedule: quarterly
|
||||||
|
|
9
.scrutinizer.yml
Normal file
9
.scrutinizer.yml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
checks:
|
||||||
|
python:
|
||||||
|
code_rating: true
|
||||||
|
duplicate_code: true
|
||||||
|
filter:
|
||||||
|
excluded_paths:
|
||||||
|
- '*/test/*'
|
||||||
|
tools:
|
||||||
|
external_code_coverage: true
|
96
CHANGELOG.md
96
CHANGELOG.md
|
@ -12,125 +12,117 @@ See GitHub Releases:
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- Fix unsafe creation of temp file for caching, and improve exception raising (#356)
|
* Fix unsafe creation of temp file for caching, and improve exception raising (#356) @kvanzuijlen
|
||||||
@kvanzuijlen
|
* [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci
|
||||||
- [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci
|
|
||||||
|
|
||||||
## [4.1.0] - 2021-01-04
|
## [4.1.0] - 2021-01-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- Add support for streaming (#336) @kvanzuijlen
|
* Add support for streaming (#336) @kvanzuijlen
|
||||||
- Add Python 3.9 final to Travis CI (#350) @sheetalsingala
|
* Add Python 3.9 final to Travis CI (#350) @sheetalsingala
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- Update copyright year (#360) @hugovk
|
* Update copyright year (#360) @hugovk
|
||||||
- Replace Travis CI with GitHub Actions (#352) @hugovk
|
* Replace Travis CI with GitHub Actions (#352) @hugovk
|
||||||
- [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci
|
* [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- Set limit to 50 by default, not 1 (#355) @hugovk
|
* Set limit to 50 by default, not 1 (#355) @hugovk
|
||||||
|
|
||||||
|
|
||||||
## [4.0.0] - 2020-10-07
|
## [4.0.0] - 2020-10-07
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- Add support for Python 3.9 (#347) @hugovk
|
* Add support for Python 3.9 (#347) @hugovk
|
||||||
|
|
||||||
## Removed
|
## Removed
|
||||||
|
|
||||||
- Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and
|
* Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and `STATUS_TOKEN_ERROR` (#348) @hugovk
|
||||||
`STATUS_TOKEN_ERROR` (#348) @hugovk
|
* Drop support for EOL Python 3.5 (#346) @hugovk
|
||||||
- Drop support for EOL Python 3.5 (#346) @hugovk
|
|
||||||
|
|
||||||
## [3.3.0] - 2020-06-25
|
## [3.3.0] - 2020-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `User.get_now_playing`: Add album and cover image to info (#330) @hugovk
|
* `User.get_now_playing`: Add album and cover image to info (#330) @hugovk
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improve handling of error responses from the API (#327) @spiritualized
|
* Improve handling of error responses from the API (#327) @spiritualized
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332)
|
* Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) @hugovk
|
||||||
@hugovk
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk
|
* Fix `artist.get_bio_content()` to return `None` if bio is empty (#326) @hugovk
|
||||||
|
|
||||||
|
|
||||||
## [3.2.1] - 2020-03-05
|
## [3.2.1] - 2020-03-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Only Python 3 is supported: don't create universal wheel (#318) @hugovk
|
* Only Python 3 is supported: don't create universal wheel (#318) @hugovk
|
||||||
- Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk
|
* Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk
|
||||||
- Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk
|
* Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk
|
||||||
|
|
||||||
## [3.2.0] - 2020-01-03
|
## [3.2.0] - 2020-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Support for Python 3.8
|
* Support for Python 3.8
|
||||||
- Store album art URLs when you call `GetTopAlbums` ([#307])
|
* Store album art URLs when you call `GetTopAlbums` ([#307])
|
||||||
- Retry paging through results on exception ([#297])
|
* Retry paging through results on exception ([#297])
|
||||||
- More error status codes from https://last.fm/api/errorcodes ([#297])
|
* More error status codes from https://last.fm/api/errorcodes ([#297])
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Respect `get_recent_tracks`' limit when there's a now playing track ([#310])
|
* Respect `get_recent_tracks`' limit when there's a now playing track ([#310])
|
||||||
- Move installable code to `src/` ([#301])
|
* Move installable code to `src/` ([#301])
|
||||||
- Update `get_weekly_artist_charts` docstring: only for `User` ([#311])
|
* Update `get_weekly_artist_charts` docstring: only for `User` ([#311])
|
||||||
- Remove Python 2 warnings, `python_requires` should be enough ([#312])
|
* Remove Python 2 warnings, `python_requires` should be enough ([#312])
|
||||||
- Use setuptools_scm to simplify versioning during release ([#316])
|
* Use setuptools_scm to simplify versioning during release ([#316])
|
||||||
- Various lint and test updates
|
* Various lint and test updates
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer
|
* Last.fm's `user.getArtistTracks` has now been deprecated by Last.fm and is no longer
|
||||||
available. Last.fm returns a "Deprecated - This type of request is no longer
|
available. Last.fm returns a "Deprecated - This type of request is no longer
|
||||||
supported" error when calling it. A future version of pylast will remove its
|
supported" error when calling it. A future version of pylast will remove its
|
||||||
`User.get_artist_tracks` altogether. ([#305])
|
`User.get_artist_tracks` altogether. ([#305])
|
||||||
|
|
||||||
- `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. Use
|
* `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version.
|
||||||
`STATUS_OPERATION_FAILED` instead.
|
Use `STATUS_OPERATION_FAILED` instead.
|
||||||
|
|
||||||
## [3.1.0] - 2019-03-07
|
## [3.1.0] - 2019-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Extract username from session via new
|
* Extract username from session via new
|
||||||
`SessionKeyGenerator.get_web_auth_session_key_username` ([#290])
|
`SessionKeyGenerator.get_web_auth_session_key_username` ([#290])
|
||||||
- `User.get_track_scrobbles` ([#298])
|
* `User.get_track_scrobbles` ([#298])
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement.
|
* `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement.
|
||||||
([#298])
|
([#298])
|
||||||
|
|
||||||
## [3.0.0] - 2019-01-01
|
## [3.0.0] - 2019-01-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
* This changelog file ([#273])
|
||||||
- This changelog file ([#273])
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Support for Python 2.7 ([#265])
|
* Support for Python 2.7 ([#265])
|
||||||
|
|
||||||
- Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` and
|
* Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE`
|
||||||
`COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282])
|
and `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282])
|
||||||
|
|
||||||
## [2.4.0] - 2018-08-08
|
## [2.4.0] - 2018-08-08
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
- Support for Python 2.7 ([#265])
|
* Support for Python 2.7 ([#265])
|
||||||
|
|
||||||
[4.2.0]: https://github.com/pylast/pylast/compare/4.1.0...4.2.0
|
[4.2.0]: https://github.com/pylast/pylast/compare/4.1.0...4.2.0
|
||||||
[4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0
|
[4.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0
|
||||||
|
|
6
COPYING
6
COPYING
|
@ -32,11 +32,11 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||||
|
|
||||||
You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||||
|
|
||||||
You must cause any modified files to carry prominent notices stating that You changed the files; and
|
You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||||
|
|
||||||
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||||
|
|
||||||
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||||
|
|
93
README.md
93
README.md
|
@ -5,7 +5,7 @@
|
||||||
[](https://pypistats.org/packages/pylast)
|
[](https://pypistats.org/packages/pylast)
|
||||||
[](https://github.com/pylast/pylast/actions)
|
[](https://github.com/pylast/pylast/actions)
|
||||||
[](https://codecov.io/gh/pylast/pylast)
|
[](https://codecov.io/gh/pylast/pylast)
|
||||||
[](https://github.com/psf/black)
|
[](https://github.com/psf/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
|
A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites
|
||||||
|
@ -15,44 +15,48 @@ Use the pydoc utility for help on usage or see [tests/](tests/) for examples.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
Install via pip:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m pip install pylast
|
||||||
|
```
|
||||||
|
|
||||||
Install latest development version:
|
Install latest development version:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast
|
python3 -m pip install -U git+https://github.com/pylast/pylast
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from requirements.txt:
|
Or from requirements.txt:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
-e https://git.hirad.it/Hirad/pylast#egg=pylast
|
-e https://github.com/pylast/pylast.git#egg=pylast
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|
||||||
- pyLast 5.3+ supports Python 3.8-3.13.
|
* pyLast 5.0+ supports Python 3.7-3.10.
|
||||||
- pyLast 5.2+ supports Python 3.8-3.12.
|
* pyLast 4.3+ supports Python 3.6-3.10.
|
||||||
- pyLast 5.1 supports Python 3.7-3.11.
|
* pyLast 4.0 - 4.2 supports Python 3.6-3.9.
|
||||||
- pyLast 5.0 supports Python 3.7-3.10.
|
* pyLast 3.2 - 3.3 supports Python 3.5-3.8.
|
||||||
- pyLast 4.3 - 4.5 supports Python 3.6-3.10.
|
* pyLast 3.0 - 3.1 supports Python 3.5-3.7.
|
||||||
- pyLast 4.0 - 4.2 supports Python 3.6-3.9.
|
* pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7.
|
||||||
- pyLast 3.2 - 3.3 supports Python 3.5-3.8.
|
* pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6.
|
||||||
- pyLast 3.0 - 3.1 supports Python 3.5-3.7.
|
* pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6.
|
||||||
- pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7.
|
* pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4.
|
||||||
- pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6.
|
* pyLast 0.5 supports Python 2, 3.
|
||||||
- pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6.
|
* pyLast < 0.5 supports Python 2.
|
||||||
- pyLast 1.0 - 1.6 supports Python 2.7, 3.3-3.4.
|
|
||||||
- pyLast 0.5 supports Python 2, 3.
|
|
||||||
- pyLast < 0.5 supports Python 2.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Simple public interface.
|
* Simple public interface.
|
||||||
- Access to all the data exposed by the Last.fm web services.
|
* Access to all the data exposed by the Last.fm web services.
|
||||||
- Scrobbling support.
|
* Scrobbling support.
|
||||||
- Full object-oriented design.
|
* Full object-oriented design.
|
||||||
- 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.
|
||||||
|
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
@ -79,43 +83,7 @@ network = pylast.LastFMNetwork(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
)
|
)
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, instead of creating `network` with a username and password, you can
|
|
||||||
authenticate with a session key:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pylast
|
|
||||||
|
|
||||||
SESSION_KEY_FILE = os.path.join(os.path.expanduser("~"), ".session_key")
|
|
||||||
network = pylast.LastFMNetwork(API_KEY, API_SECRET)
|
|
||||||
if not os.path.exists(SESSION_KEY_FILE):
|
|
||||||
skg = pylast.SessionKeyGenerator(network)
|
|
||||||
url = skg.get_web_auth_url()
|
|
||||||
|
|
||||||
print(f"Please authorize this script to access your account: {url}\n")
|
|
||||||
import time
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
webbrowser.open(url)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
session_key = skg.get_web_auth_session_key(url)
|
|
||||||
with open(SESSION_KEY_FILE, "w") as f:
|
|
||||||
f.write(session_key)
|
|
||||||
break
|
|
||||||
except pylast.WSError:
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
session_key = open(SESSION_KEY_FILE).read()
|
|
||||||
|
|
||||||
network.session_key = session_key
|
|
||||||
```
|
|
||||||
|
|
||||||
And away we go:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Now you can use that object everywhere
|
# Now you can use that object everywhere
|
||||||
track = network.get_track("Iron Maiden", "The Nomad")
|
track = network.get_track("Iron Maiden", "The Nomad")
|
||||||
track.love()
|
track.love()
|
||||||
|
@ -136,9 +104,8 @@ integration and unit tests with Last.fm, and plenty of code examples.
|
||||||
|
|
||||||
For integration tests you need a test account at Last.fm that will become cluttered with
|
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
|
test data, and an API key and secret. Either copy
|
||||||
[example_test_pylast.yaml](https://github.com/pylast/pylast/blob/main/example_test_pylast.yaml)
|
[example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out
|
||||||
to test_pylast.yaml and fill out the credentials, or set them as environment variables
|
the credentials, or set them as environment variables like:
|
||||||
like:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
|
export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
# Release Checklist
|
# Release Checklist
|
||||||
|
|
||||||
- [ ] Get `main` to the appropriate code release state.
|
- [ ] Get `main` to the appropriate code release state.
|
||||||
[GitHub Actions](https://github.com/pylast/pylast/actions) should be running
|
[GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for
|
||||||
cleanly for all merges to `main`.
|
all merges to `main`.
|
||||||
[](https://github.com/pylast/pylast/actions)
|
[](https://github.com/pylast/pylast/actions)
|
||||||
|
|
||||||
- [ ] Edit release draft, adjust text if needed:
|
- [ ] Edit release draft, adjust text if needed:
|
||||||
|
@ -12,8 +12,7 @@
|
||||||
|
|
||||||
- [ ] Publish release
|
- [ ] Publish release
|
||||||
|
|
||||||
- [ ] Check the tagged
|
- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
|
||||||
[GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
|
|
||||||
has deployed to [PyPI](https://pypi.org/project/pylast/#history)
|
has deployed to [PyPI](https://pypi.org/project/pylast/#history)
|
||||||
|
|
||||||
- [ ] Check installation:
|
- [ ] Check installation:
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
username: TODO_ENTER_YOURS_HERE
|
username: TODO_ENTER_YOURS_HERE
|
||||||
password_hash: TODO_ENTER_YOURS_HERE
|
password_hash: TODO_ENTER_YOURS_HERE
|
||||||
api_key: TODO_ENTER_YOURS_HERE
|
api_key: TODO_ENTER_YOURS_HERE
|
||||||
api_secret: TODO_ENTER_YOURS_HERE
|
api_secret: TODO_ENTER_YOURS_HERE
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
[build-system]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
requires = [
|
|
||||||
"hatch-vcs",
|
|
||||||
"hatchling",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "pylast"
|
|
||||||
description = "A Python interface to Last.fm and Libre.fm"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"Last.fm",
|
|
||||||
"music",
|
|
||||||
"scrobble",
|
|
||||||
"scrobbling",
|
|
||||||
]
|
|
||||||
license = { text = "Apache-2.0" }
|
|
||||||
maintainers = [
|
|
||||||
{ name = "Hugo van Kemenade" },
|
|
||||||
]
|
|
||||||
authors = [
|
|
||||||
{ name = "Amr Hassan <amr.hassan@gmail.com> and Contributors", email = "amr.hassan@gmail.com" },
|
|
||||||
]
|
|
||||||
requires-python = ">=3.8"
|
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 5 - Production/Stable",
|
|
||||||
"License :: OSI Approved :: Apache Software License",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
|
||||||
"Topic :: Internet",
|
|
||||||
"Topic :: Multimedia :: Sound/Audio",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
||||||
]
|
|
||||||
dynamic = [
|
|
||||||
"version",
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"httpx",
|
|
||||||
]
|
|
||||||
optional-dependencies.tests = [
|
|
||||||
"flaky",
|
|
||||||
"pytest",
|
|
||||||
"pytest-cov",
|
|
||||||
"pytest-random-order",
|
|
||||||
"pyyaml",
|
|
||||||
]
|
|
||||||
urls.Changelog = "https://github.com/pylast/pylast/releases"
|
|
||||||
urls.Homepage = "https://github.com/pylast/pylast"
|
|
||||||
urls.Source = "https://github.com/pylast/pylast"
|
|
||||||
|
|
||||||
[tool.hatch]
|
|
||||||
version.source = "vcs"
|
|
||||||
|
|
||||||
[tool.hatch.version.raw-options]
|
|
||||||
local_scheme = "no-local-version"
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
fix = true
|
|
||||||
|
|
||||||
lint.select = [
|
|
||||||
"C4", # flake8-comprehensions
|
|
||||||
"E", # pycodestyle errors
|
|
||||||
"EM", # flake8-errmsg
|
|
||||||
"F", # pyflakes errors
|
|
||||||
"I", # isort
|
|
||||||
"ISC", # flake8-implicit-str-concat
|
|
||||||
"LOG", # flake8-logging
|
|
||||||
"PGH", # pygrep-hooks
|
|
||||||
"RUF022", # unsorted-dunder-all
|
|
||||||
"RUF100", # unused noqa (yesqa)
|
|
||||||
"UP", # pyupgrade
|
|
||||||
"W", # pycodestyle warnings
|
|
||||||
"YTT", # flake8-2020
|
|
||||||
]
|
|
||||||
lint.extend-ignore = [
|
|
||||||
"E203", # Whitespace before ':'
|
|
||||||
"E221", # Multiple spaces before operator
|
|
||||||
"E226", # Missing whitespace around arithmetic operator
|
|
||||||
"E241", # Multiple spaces after ','
|
|
||||||
]
|
|
||||||
lint.isort.known-first-party = [
|
|
||||||
"pylast",
|
|
||||||
]
|
|
||||||
lint.isort.required-imports = [
|
|
||||||
"from __future__ import annotations",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pyproject-fmt]
|
|
||||||
max_supported_python = "3.13"
|
|
58
setup.cfg
Normal file
58
setup.cfg
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
[metadata]
|
||||||
|
name = pylast
|
||||||
|
description = A Python interface to Last.fm and Libre.fm
|
||||||
|
long_description = file: README.md
|
||||||
|
long_description_content_type = text/markdown
|
||||||
|
url = https://github.com/pylast/pylast
|
||||||
|
author = Amr Hassan <amr.hassan@gmail.com> and Contributors
|
||||||
|
author_email = amr.hassan@gmail.com
|
||||||
|
maintainer = Hugo van Kemenade
|
||||||
|
license = Apache-2.0
|
||||||
|
license_file = LICENSE.txt
|
||||||
|
classifiers =
|
||||||
|
Development Status :: 5 - Production/Stable
|
||||||
|
License :: OSI Approved :: Apache Software License
|
||||||
|
Programming Language :: Python :: 3
|
||||||
|
Programming Language :: Python :: 3 :: Only
|
||||||
|
Programming Language :: Python :: 3.7
|
||||||
|
Programming Language :: Python :: 3.8
|
||||||
|
Programming Language :: Python :: 3.9
|
||||||
|
Programming Language :: Python :: 3.10
|
||||||
|
Programming Language :: Python :: 3.11
|
||||||
|
Programming Language :: Python :: Implementation :: CPython
|
||||||
|
Programming Language :: Python :: Implementation :: PyPy
|
||||||
|
Topic :: Internet
|
||||||
|
Topic :: Multimedia :: Sound/Audio
|
||||||
|
Topic :: Software Development :: Libraries :: Python Modules
|
||||||
|
keywords =
|
||||||
|
Last.fm
|
||||||
|
music
|
||||||
|
scrobble
|
||||||
|
scrobbling
|
||||||
|
|
||||||
|
[options]
|
||||||
|
packages = find:
|
||||||
|
install_requires =
|
||||||
|
httpx
|
||||||
|
importlib-metadata;python_version < '3.8'
|
||||||
|
python_requires = >=3.7
|
||||||
|
package_dir = =src
|
||||||
|
setup_requires =
|
||||||
|
setuptools-scm
|
||||||
|
|
||||||
|
[options.packages.find]
|
||||||
|
where = src
|
||||||
|
|
||||||
|
[options.extras_require]
|
||||||
|
tests =
|
||||||
|
flaky
|
||||||
|
pytest
|
||||||
|
pytest-cov
|
||||||
|
pytest-random-order
|
||||||
|
pyyaml
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
max_line_length = 88
|
||||||
|
|
||||||
|
[tool:isort]
|
||||||
|
profile = black
|
12
setup.py
Executable file
12
setup.py
Executable file
|
@ -0,0 +1,12 @@
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
|
||||||
|
def local_scheme(version: str) -> str:
|
||||||
|
"""Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2)
|
||||||
|
to be able to upload to Test PyPI"""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
setup(
|
||||||
|
use_scm_version={"local_scheme": local_scheme},
|
||||||
|
)
|
|
@ -1,9 +1,9 @@
|
||||||
#
|
#
|
||||||
# pylast -
|
# pylast -
|
||||||
# A Python interface to Last.fm and music.lonestar.it
|
# A Python interface to Last.fm and Libre.fm
|
||||||
#
|
#
|
||||||
# Copyright 2008-2010 Amr Hassan
|
# Copyright 2008-2010 Amr Hassan
|
||||||
# Copyright 2013-2021 hugovk
|
# Copyright 2013-2022 hugovk
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
|
@ -22,27 +22,52 @@ from __future__ import annotations
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import hashlib
|
import hashlib
|
||||||
import html.entities
|
|
||||||
import importlib.metadata
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shelve
|
import shelve
|
||||||
import ssl
|
import ssl
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import xml.dom
|
from xml.dom import minidom
|
||||||
from urllib.parse import quote_plus
|
|
||||||
from xml.dom import Node, minidom
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python 3.8+
|
||||||
|
import importlib.metadata as importlib_metadata
|
||||||
|
except ImportError:
|
||||||
|
# Python 3.7 and lower
|
||||||
|
import importlib_metadata # type: ignore
|
||||||
|
|
||||||
|
from .exceptions import MalformedResponseError, NetworkError, PyLastError, WSError
|
||||||
|
from .utils import (
|
||||||
|
_collect_nodes,
|
||||||
|
_number,
|
||||||
|
_parse_response,
|
||||||
|
_string_output,
|
||||||
|
_unescape_htmlentity,
|
||||||
|
_unicode,
|
||||||
|
_url_safe,
|
||||||
|
cleanup_nodes,
|
||||||
|
md5,
|
||||||
|
)
|
||||||
|
|
||||||
__author__ = "Amr Hassan, hugovk, Mice Pápai"
|
__author__ = "Amr Hassan, hugovk, Mice Pápai"
|
||||||
__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai"
|
__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2022 hugovk, 2017 Mice Pápai"
|
||||||
__license__ = "apache2"
|
__license__ = "apache2"
|
||||||
__email__ = "amr.hassan@gmail.com"
|
__email__ = "amr.hassan@gmail.com"
|
||||||
__version__ = importlib.metadata.version(__name__)
|
__version__ = importlib_metadata.version(__name__)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Exceptions
|
||||||
|
MalformedResponseError,
|
||||||
|
NetworkError,
|
||||||
|
PyLastError,
|
||||||
|
WSError,
|
||||||
|
# Utils
|
||||||
|
cleanup_nodes,
|
||||||
|
md5,
|
||||||
|
]
|
||||||
|
|
||||||
# 1 : This error does not exist
|
# 1 : This error does not exist
|
||||||
STATUS_INVALID_SERVICE = 2
|
STATUS_INVALID_SERVICE = 2
|
||||||
|
@ -529,25 +554,26 @@ class _Network:
|
||||||
|
|
||||||
def scrobble(
|
def scrobble(
|
||||||
self,
|
self,
|
||||||
artist: str,
|
artist,
|
||||||
title: str,
|
title,
|
||||||
timestamp: int,
|
timestamp,
|
||||||
album: str | None = None,
|
album=None,
|
||||||
album_artist: str | None = None,
|
album_artist=None,
|
||||||
track_number: int | None = None,
|
track_number=None,
|
||||||
duration: int | None = None,
|
duration=None,
|
||||||
stream_id: str | None = None,
|
stream_id=None,
|
||||||
context: str | None = None,
|
context=None,
|
||||||
mbid: str | None = None,
|
mbid=None,
|
||||||
):
|
):
|
||||||
|
|
||||||
"""Used to add a track-play to a user's profile.
|
"""Used to add a track-play to a user's profile.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
artist (Required) : The artist name.
|
artist (Required) : The artist name.
|
||||||
title (Required) : The track name.
|
title (Required) : The track name.
|
||||||
timestamp (Required) : The time the track started playing, in Unix
|
timestamp (Required) : The time the track started playing, in UNIX
|
||||||
timestamp format (integer number of seconds since 00:00:00,
|
timestamp format (integer number of seconds since 00:00:00,
|
||||||
January 1st 1970 UTC).
|
January 1st 1970 UTC). This must be in the UTC time zone.
|
||||||
album (Optional) : The album name.
|
album (Optional) : The album name.
|
||||||
album_artist (Optional) : The album artist - if this differs from
|
album_artist (Optional) : The album artist - if this differs from
|
||||||
the track artist.
|
the track artist.
|
||||||
|
@ -593,6 +619,7 @@ class _Network:
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
for i in range(len(tracks_to_scrobble)):
|
for i in range(len(tracks_to_scrobble)):
|
||||||
|
|
||||||
params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"]
|
params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"]
|
||||||
params[f"track[{i}]"] = tracks_to_scrobble[i]["title"]
|
params[f"track[{i}]"] = tracks_to_scrobble[i]["title"]
|
||||||
|
|
||||||
|
@ -613,6 +640,7 @@ class _Network:
|
||||||
}
|
}
|
||||||
|
|
||||||
for arg in additional_args:
|
for arg in additional_args:
|
||||||
|
|
||||||
if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]:
|
if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]:
|
||||||
if arg in args_map_to:
|
if arg in args_map_to:
|
||||||
maps_to = args_map_to[arg]
|
maps_to = args_map_to[arg]
|
||||||
|
@ -628,6 +656,7 @@ class _Network:
|
||||||
|
|
||||||
|
|
||||||
class LastFMNetwork(_Network):
|
class LastFMNetwork(_Network):
|
||||||
|
|
||||||
"""A Last.fm network object
|
"""A Last.fm network object
|
||||||
|
|
||||||
api_key: a provided API_KEY
|
api_key: a provided API_KEY
|
||||||
|
@ -705,7 +734,7 @@ class LastFMNetwork(_Network):
|
||||||
|
|
||||||
class LibreFMNetwork(_Network):
|
class LibreFMNetwork(_Network):
|
||||||
"""
|
"""
|
||||||
A preconfigured _Network object for music.lonestar.it
|
A preconfigured _Network object for Libre.fm
|
||||||
|
|
||||||
api_key: a provided API_KEY
|
api_key: a provided API_KEY
|
||||||
api_secret: a provided API_SECRET
|
api_secret: a provided API_SECRET
|
||||||
|
@ -726,28 +755,29 @@ class LibreFMNetwork(_Network):
|
||||||
username: str = "",
|
username: str = "",
|
||||||
password_hash: str = "",
|
password_hash: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name="music.lonestar.it",
|
name="Libre.fm",
|
||||||
homepage="https://music.lonestar.it",
|
homepage="https://libre.fm",
|
||||||
ws_server=("music.lonestar.it", "/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,
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
domain_names={
|
domain_names={
|
||||||
DOMAIN_ENGLISH: "music.lonestar.it",
|
DOMAIN_ENGLISH: "libre.fm",
|
||||||
DOMAIN_GERMAN: "music.lonestar.it",
|
DOMAIN_GERMAN: "libre.fm",
|
||||||
DOMAIN_SPANISH: "music.lonestar.it",
|
DOMAIN_SPANISH: "libre.fm",
|
||||||
DOMAIN_FRENCH: "music.lonestar.it",
|
DOMAIN_FRENCH: "libre.fm",
|
||||||
DOMAIN_ITALIAN: "music.lonestar.it",
|
DOMAIN_ITALIAN: "libre.fm",
|
||||||
DOMAIN_POLISH: "music.lonestar.it",
|
DOMAIN_POLISH: "libre.fm",
|
||||||
DOMAIN_PORTUGUESE: "music.lonestar.it",
|
DOMAIN_PORTUGUESE: "libre.fm",
|
||||||
DOMAIN_SWEDISH: "music.lonestar.it",
|
DOMAIN_SWEDISH: "libre.fm",
|
||||||
DOMAIN_TURKISH: "music.lonestar.it",
|
DOMAIN_TURKISH: "libre.fm",
|
||||||
DOMAIN_RUSSIAN: "music.lonestar.it",
|
DOMAIN_RUSSIAN: "libre.fm",
|
||||||
DOMAIN_JAPANESE: "music.lonestar.it",
|
DOMAIN_JAPANESE: "libre.fm",
|
||||||
DOMAIN_CHINESE: "music.lonestar.it",
|
DOMAIN_CHINESE: "libre.fm",
|
||||||
},
|
},
|
||||||
urls={
|
urls={
|
||||||
"album": "artist/%(artist)s/album/%(album)s",
|
"album": "artist/%(artist)s/album/%(album)s",
|
||||||
|
@ -893,7 +923,6 @@ class _Request:
|
||||||
username = "" if username is None else f"?username={username}"
|
username = "" if username is None else f"?username={username}"
|
||||||
|
|
||||||
(host_name, host_subdir) = self.network.ws_server
|
(host_name, host_subdir) = self.network.ws_server
|
||||||
timeout = httpx.Timeout(5, read=10)
|
|
||||||
|
|
||||||
if self.network.is_proxy_enabled():
|
if self.network.is_proxy_enabled():
|
||||||
client = httpx.Client(
|
client = httpx.Client(
|
||||||
|
@ -901,14 +930,12 @@ class _Request:
|
||||||
base_url=f"https://{host_name}",
|
base_url=f"https://{host_name}",
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
proxies=self.network.proxy,
|
proxies=self.network.proxy,
|
||||||
timeout=timeout,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
client = httpx.Client(
|
client = httpx.Client(
|
||||||
verify=SSL_CONTEXT,
|
verify=SSL_CONTEXT,
|
||||||
base_url=f"https://{host_name}",
|
base_url=f"https://{host_name}",
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
timeout=timeout,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -930,7 +957,7 @@ class _Request:
|
||||||
client.close()
|
client.close()
|
||||||
return response_text
|
return response_text
|
||||||
|
|
||||||
def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document:
|
def execute(self, cacheable: bool = False) -> minidom.Document:
|
||||||
"""Returns the XML DOM response of the POST Request from the server"""
|
"""Returns the XML DOM response of the POST Request from the server"""
|
||||||
|
|
||||||
if self.network.is_caching_enabled() and cacheable:
|
if self.network.is_caching_enabled() and cacheable:
|
||||||
|
@ -1081,13 +1108,6 @@ Image = collections.namedtuple(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _string_output(func):
|
|
||||||
def r(*args):
|
|
||||||
return str(func(*args))
|
|
||||||
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
class _BaseObject:
|
class _BaseObject:
|
||||||
"""An abstract webservices object."""
|
"""An abstract webservices object."""
|
||||||
|
|
||||||
|
@ -1158,7 +1178,7 @@ class _BaseObject:
|
||||||
|
|
||||||
def get_wiki_published_date(self):
|
def get_wiki_published_date(self):
|
||||||
"""
|
"""
|
||||||
Returns the date on which the wiki was published.
|
Returns the summary of the wiki.
|
||||||
Only for Album/Track.
|
Only for Album/Track.
|
||||||
"""
|
"""
|
||||||
return self.get_wiki("published")
|
return self.get_wiki("published")
|
||||||
|
@ -1172,7 +1192,7 @@ class _BaseObject:
|
||||||
|
|
||||||
def get_wiki_content(self):
|
def get_wiki_content(self):
|
||||||
"""
|
"""
|
||||||
Returns the content of the wiki.
|
Returns the summary of the wiki.
|
||||||
Only for Album/Track.
|
Only for Album/Track.
|
||||||
"""
|
"""
|
||||||
return self.get_wiki("content")
|
return self.get_wiki("content")
|
||||||
|
@ -1242,10 +1262,8 @@ class _Chartable(_BaseObject):
|
||||||
from_date value to the to_date value.
|
from_date value to the to_date value.
|
||||||
chart_kind should be one of "album", "artist" or "track"
|
chart_kind should be one of "album", "artist" or "track"
|
||||||
"""
|
"""
|
||||||
import sys
|
|
||||||
|
|
||||||
method = ".getWeekly" + chart_kind.title() + "Chart"
|
method = ".getWeekly" + chart_kind.title() + "Chart"
|
||||||
chart_type = getattr(sys.modules[__name__], chart_kind.title())
|
chart_type = eval(chart_kind.title()) # string to type
|
||||||
|
|
||||||
params = self._get_params()
|
params = self._get_params()
|
||||||
if from_date and to_date:
|
if from_date and to_date:
|
||||||
|
@ -1357,11 +1375,11 @@ class _Taggable(_BaseObject):
|
||||||
new_tags.append(tag)
|
new_tags.append(tag)
|
||||||
|
|
||||||
for i in range(0, len(old_tags)):
|
for i in range(0, len(old_tags)):
|
||||||
if c_old_tags[i] not in c_new_tags:
|
if not c_old_tags[i] in c_new_tags:
|
||||||
to_remove.append(old_tags[i])
|
to_remove.append(old_tags[i])
|
||||||
|
|
||||||
for i in range(0, len(new_tags)):
|
for i in range(0, len(new_tags)):
|
||||||
if c_new_tags[i] not in c_old_tags:
|
if not c_new_tags[i] in c_old_tags:
|
||||||
to_add.append(new_tags[i])
|
to_add.append(new_tags[i])
|
||||||
|
|
||||||
self.remove_tags(to_remove)
|
self.remove_tags(to_remove)
|
||||||
|
@ -1387,81 +1405,6 @@ class _Taggable(_BaseObject):
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
|
|
||||||
class PyLastError(Exception):
|
|
||||||
"""Generic exception raised by PyLast"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class WSError(PyLastError):
|
|
||||||
"""Exception related to the Network web service"""
|
|
||||||
|
|
||||||
def __init__(self, network, status, details) -> None:
|
|
||||||
self.status = status
|
|
||||||
self.details = details
|
|
||||||
self.network = network
|
|
||||||
|
|
||||||
@_string_output
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.details
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.status
|
|
||||||
|
|
||||||
|
|
||||||
class MalformedResponseError(PyLastError):
|
|
||||||
"""Exception conveying a malformed response from the music network."""
|
|
||||||
|
|
||||||
def __init__(self, network, underlying_error) -> None:
|
|
||||||
self.network = network
|
|
||||||
self.underlying_error = underlying_error
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return (
|
|
||||||
f"Malformed response from {self.network.name}. "
|
|
||||||
f"Underlying error: {self.underlying_error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkError(PyLastError):
|
|
||||||
"""Exception conveying a problem in sending a request to Last.fm"""
|
|
||||||
|
|
||||||
def __init__(self, network, underlying_error) -> None:
|
|
||||||
self.network = network
|
|
||||||
self.underlying_error = underlying_error
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"NetworkError: {self.underlying_error}"
|
|
||||||
|
|
||||||
|
|
||||||
class _Opus(_Taggable):
|
class _Opus(_Taggable):
|
||||||
"""An album or track."""
|
"""An album or track."""
|
||||||
|
|
||||||
|
@ -1509,7 +1452,7 @@ class _Opus(_Taggable):
|
||||||
return f"{self.get_artist().get_name()} - {self.get_title()}"
|
return f"{self.get_artist().get_name()} - {self.get_title()}"
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if type(self) is not type(other):
|
if type(self) != type(other):
|
||||||
return False
|
return False
|
||||||
a = self.get_title().lower()
|
a = self.get_title().lower()
|
||||||
b = other.get_title().lower()
|
b = other.get_title().lower()
|
||||||
|
@ -1547,7 +1490,7 @@ class _Opus(_Taggable):
|
||||||
return self.info["image"][size]
|
return self.info["image"][size]
|
||||||
|
|
||||||
def get_title(self, properly_capitalized: bool = False):
|
def get_title(self, properly_capitalized: bool = False):
|
||||||
"""Returns the album or track title."""
|
"""Returns the artist or track title."""
|
||||||
if properly_capitalized:
|
if properly_capitalized:
|
||||||
self.title = _extract(
|
self.title = _extract(
|
||||||
self._request(self.ws_prefix + ".getInfo", True), "name"
|
self._request(self.ws_prefix + ".getInfo", True), "name"
|
||||||
|
@ -2299,8 +2242,8 @@ class User(_Chartable):
|
||||||
self,
|
self,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
cacheable: bool = True,
|
cacheable: bool = True,
|
||||||
time_from: int | None = None,
|
time_from=None,
|
||||||
time_to: int | None = None,
|
time_to=None,
|
||||||
stream: bool = False,
|
stream: bool = False,
|
||||||
now_playing: bool = False,
|
now_playing: bool = False,
|
||||||
):
|
):
|
||||||
|
@ -2311,11 +2254,13 @@ class User(_Chartable):
|
||||||
Parameters:
|
Parameters:
|
||||||
limit : If None, it will try to pull all the available data.
|
limit : If None, it will try to pull all the available data.
|
||||||
from (Optional) : Beginning timestamp of a range - only display
|
from (Optional) : Beginning timestamp of a range - only display
|
||||||
scrobbles after this time, in Unix timestamp format (integer
|
scrobbles after this time, in UNIX timestamp format (integer
|
||||||
number of seconds since 00:00:00, January 1st 1970 UTC).
|
number of seconds since 00:00:00, January 1st 1970 UTC). This
|
||||||
|
must be in the UTC time zone.
|
||||||
to (Optional) : End timestamp of a range - only display scrobbles
|
to (Optional) : End timestamp of a range - only display scrobbles
|
||||||
before this time, in Unix timestamp format (integer number of
|
before this time, in UNIX timestamp format (integer number of
|
||||||
seconds since 00:00:00, January 1st 1970 UTC).
|
seconds since 00:00:00, January 1st 1970 UTC). This must be in
|
||||||
|
the UTC time zone.
|
||||||
stream: If True, it will yield tracks as soon as a page has been retrieved.
|
stream: If True, it will yield tracks as soon as a page has been retrieved.
|
||||||
|
|
||||||
This method uses caching. Enable caching only if you're pulling a
|
This method uses caching. Enable caching only if you're pulling a
|
||||||
|
@ -2384,7 +2329,7 @@ class User(_Chartable):
|
||||||
return _extract(doc, "registered")
|
return _extract(doc, "registered")
|
||||||
|
|
||||||
def get_unixtime_registered(self):
|
def get_unixtime_registered(self):
|
||||||
"""Returns the user's registration date as a Unix timestamp."""
|
"""Returns the user's registration date as a UNIX timestamp."""
|
||||||
|
|
||||||
doc = self._request(self.ws_prefix + ".getInfo", True)
|
doc = self._request(self.ws_prefix + ".getInfo", True)
|
||||||
|
|
||||||
|
@ -2712,90 +2657,6 @@ class TrackSearch(_Search):
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
|
|
||||||
def md5(text):
|
|
||||||
"""Returns the md5 hash of a string."""
|
|
||||||
|
|
||||||
h = hashlib.md5()
|
|
||||||
h.update(_unicode(text).encode("utf-8"))
|
|
||||||
|
|
||||||
return h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _unicode(text):
|
|
||||||
if isinstance(text, bytes):
|
|
||||||
return str(text, "utf-8")
|
|
||||||
else:
|
|
||||||
return str(text)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_nodes(doc):
|
|
||||||
"""
|
|
||||||
Remove text nodes containing only whitespace
|
|
||||||
"""
|
|
||||||
for node in doc.documentElement.childNodes:
|
|
||||||
if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace():
|
|
||||||
doc.documentElement.removeChild(node)
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_nodes(
|
|
||||||
limit, sender, method_name, cacheable, params=None, stream: bool = False
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Returns a sequence of dom.Node objects about as close to limit as possible
|
|
||||||
"""
|
|
||||||
if not params:
|
|
||||||
params = sender._get_params()
|
|
||||||
|
|
||||||
def _stream_collect_nodes():
|
|
||||||
node_count = 0
|
|
||||||
page = 1
|
|
||||||
end_of_pages = False
|
|
||||||
|
|
||||||
while not end_of_pages and (not limit or (limit and node_count < limit)):
|
|
||||||
params["page"] = str(page)
|
|
||||||
|
|
||||||
tries = 1
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
doc = sender._request(method_name, cacheable, params)
|
|
||||||
break # success
|
|
||||||
except Exception as e:
|
|
||||||
if tries >= 3:
|
|
||||||
raise PyLastError() from e
|
|
||||||
# Wait and try again
|
|
||||||
time.sleep(1)
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
doc = cleanup_nodes(doc)
|
|
||||||
|
|
||||||
# break if there are no child nodes
|
|
||||||
if not doc.documentElement.childNodes:
|
|
||||||
break
|
|
||||||
main = doc.documentElement.childNodes[0]
|
|
||||||
|
|
||||||
if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"):
|
|
||||||
total_pages = _number(
|
|
||||||
main.getAttribute("totalPages") or main.getAttribute("totalpages")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = "No total pages attribute"
|
|
||||||
raise PyLastError(msg)
|
|
||||||
|
|
||||||
for node in main.childNodes:
|
|
||||||
if not node.nodeType == xml.dom.Node.TEXT_NODE and (
|
|
||||||
not limit or (node_count < limit)
|
|
||||||
):
|
|
||||||
node_count += 1
|
|
||||||
yield node
|
|
||||||
|
|
||||||
end_of_pages = page >= total_pages
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
return _stream_collect_nodes() if stream else list(_stream_collect_nodes())
|
|
||||||
|
|
||||||
|
|
||||||
def _extract(node, name, index: int = 0):
|
def _extract(node, name, index: int = 0):
|
||||||
"""Extracts a value from the xml string"""
|
"""Extracts a value from the xml string"""
|
||||||
|
|
||||||
|
@ -2871,51 +2732,3 @@ def _extract_tracks(doc, network):
|
||||||
artist = _extract(node, "name", 1)
|
artist = _extract(node, "name", 1)
|
||||||
seq.append(Track(artist, name, network))
|
seq.append(Track(artist, name, network))
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
|
|
||||||
def _url_safe(text):
|
|
||||||
"""Does all kinds of tricks on a text to make it safe to use in a URL."""
|
|
||||||
|
|
||||||
return quote_plus(quote_plus(str(text))).lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _number(string):
|
|
||||||
"""
|
|
||||||
Extracts an int from a string.
|
|
||||||
Returns a 0 if None or an empty string was passed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not string:
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
return int(string)
|
|
||||||
except ValueError:
|
|
||||||
return float(string)
|
|
||||||
|
|
||||||
|
|
||||||
def _unescape_htmlentity(string):
|
|
||||||
mapping = html.entities.name2codepoint
|
|
||||||
for key in mapping:
|
|
||||||
string = string.replace(f"&{key};", chr(mapping[key]))
|
|
||||||
|
|
||||||
return string
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_response(response: str) -> xml.dom.minidom.Document:
|
|
||||||
response = str(response).replace("opensearch:", "")
|
|
||||||
try:
|
|
||||||
doc = minidom.parseString(response)
|
|
||||||
except xml.parsers.expat.ExpatError:
|
|
||||||
# Try again. For performance, we only remove when needed in rare cases.
|
|
||||||
doc = minidom.parseString(_remove_invalid_xml_chars(response))
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_invalid_xml_chars(string: str) -> str:
|
|
||||||
return re.sub(
|
|
||||||
r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# End of file
|
|
||||||
|
|
78
src/pylast/exceptions.py
Normal file
78
src/pylast/exceptions.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .utils import _string_output
|
||||||
|
|
||||||
|
|
||||||
|
class PyLastError(Exception):
|
||||||
|
"""Generic exception raised by PyLast"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WSError(PyLastError):
|
||||||
|
"""Exception related to the Network web service"""
|
||||||
|
|
||||||
|
def __init__(self, network, status, details) -> None:
|
||||||
|
self.status = status
|
||||||
|
self.details = details
|
||||||
|
self.network = network
|
||||||
|
|
||||||
|
@_string_output
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.details
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.status
|
||||||
|
|
||||||
|
|
||||||
|
class MalformedResponseError(PyLastError):
|
||||||
|
"""Exception conveying a malformed response from the music network."""
|
||||||
|
|
||||||
|
def __init__(self, network, underlying_error) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.underlying_error = underlying_error
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"Malformed response from {self.network.name}. "
|
||||||
|
f"Underlying error: {self.underlying_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkError(PyLastError):
|
||||||
|
"""Exception conveying a problem in sending a request to Last.fm"""
|
||||||
|
|
||||||
|
def __init__(self, network, underlying_error) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.underlying_error = underlying_error
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"NetworkError: {self.underlying_error}"
|
159
src/pylast/utils.py
Normal file
159
src/pylast/utils.py
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import warnings
|
||||||
|
import xml
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from xml.dom import Node, minidom
|
||||||
|
|
||||||
|
import pylast
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_nodes(doc: minidom.Document) -> minidom.Document:
|
||||||
|
"""
|
||||||
|
cleanup_nodes is deprecated and will be removed in pylast 6.0
|
||||||
|
"""
|
||||||
|
warnings.warn(
|
||||||
|
"cleanup_nodes is deprecated and will be removed in pylast 6.0",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _cleanup_nodes(doc)
|
||||||
|
|
||||||
|
|
||||||
|
def md5(text: str) -> str:
|
||||||
|
"""Returns the md5 hash of a string."""
|
||||||
|
|
||||||
|
h = hashlib.md5()
|
||||||
|
h.update(_unicode(text).encode("utf-8"))
|
||||||
|
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_nodes(
|
||||||
|
limit, sender, method_name, cacheable, params=None, stream: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns a sequence of dom.Node objects about as close to limit as possible
|
||||||
|
"""
|
||||||
|
if not params:
|
||||||
|
params = sender._get_params()
|
||||||
|
|
||||||
|
def _stream_collect_nodes():
|
||||||
|
node_count = 0
|
||||||
|
page = 1
|
||||||
|
end_of_pages = False
|
||||||
|
|
||||||
|
while not end_of_pages and (not limit or (limit and node_count < limit)):
|
||||||
|
params["page"] = str(page)
|
||||||
|
|
||||||
|
tries = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
doc = sender._request(method_name, cacheable, params)
|
||||||
|
break # success
|
||||||
|
except Exception as e:
|
||||||
|
if tries >= 3:
|
||||||
|
raise pylast.PyLastError() from e
|
||||||
|
# Wait and try again
|
||||||
|
time.sleep(1)
|
||||||
|
tries += 1
|
||||||
|
|
||||||
|
doc = _cleanup_nodes(doc)
|
||||||
|
|
||||||
|
# break if there are no child nodes
|
||||||
|
if not doc.documentElement.childNodes:
|
||||||
|
break
|
||||||
|
main = doc.documentElement.childNodes[0]
|
||||||
|
|
||||||
|
if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"):
|
||||||
|
total_pages = _number(
|
||||||
|
main.getAttribute("totalPages") or main.getAttribute("totalpages")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise pylast.PyLastError("No total pages attribute")
|
||||||
|
|
||||||
|
for node in main.childNodes:
|
||||||
|
if not node.nodeType == xml.dom.Node.TEXT_NODE and (
|
||||||
|
not limit or (node_count < limit)
|
||||||
|
):
|
||||||
|
node_count += 1
|
||||||
|
yield node
|
||||||
|
|
||||||
|
end_of_pages = page >= total_pages
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return _stream_collect_nodes() if stream else list(_stream_collect_nodes())
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_nodes(doc: minidom.Document) -> minidom.Document:
|
||||||
|
"""
|
||||||
|
Remove text nodes containing only whitespace
|
||||||
|
"""
|
||||||
|
for node in doc.documentElement.childNodes:
|
||||||
|
if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace():
|
||||||
|
doc.documentElement.removeChild(node)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _number(string: str | None) -> float:
|
||||||
|
"""
|
||||||
|
Extracts an int from a string.
|
||||||
|
Returns a 0 if None or an empty string was passed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not string:
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return int(string)
|
||||||
|
except ValueError:
|
||||||
|
return float(string)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(response: str) -> xml.dom.minidom.Document:
|
||||||
|
response = str(response).replace("opensearch:", "")
|
||||||
|
try:
|
||||||
|
doc = minidom.parseString(response)
|
||||||
|
except xml.parsers.expat.ExpatError:
|
||||||
|
# Try again. For performance, we only remove when needed in rare cases.
|
||||||
|
doc = minidom.parseString(_remove_invalid_xml_chars(response))
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_invalid_xml_chars(string: str) -> str:
|
||||||
|
return re.sub(
|
||||||
|
r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_output(func):
|
||||||
|
def r(*args):
|
||||||
|
return str(func(*args))
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _unescape_htmlentity(string: str) -> str:
|
||||||
|
mapping = html.entities.name2codepoint
|
||||||
|
for key in mapping:
|
||||||
|
string = string.replace(f"&{key};", chr(mapping[key]))
|
||||||
|
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def _unicode(text: bytes | str) -> str:
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
return str(text, "utf-8")
|
||||||
|
else:
|
||||||
|
return str(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_safe(text: str) -> str:
|
||||||
|
"""Does all kinds of tricks on a text to make it safe to use in a URL."""
|
||||||
|
|
||||||
|
return quote_plus(quote_plus(str(text))).lower()
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import TestPyLastWithLastFm
|
from .test_pylast import TestPyLastWithLastFm
|
||||||
|
@ -96,8 +94,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
|
||||||
image = album.get_cover_image()
|
image = album.get_cover_image()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert image.startswith("https://")
|
self.assert_startswith(image, "https://")
|
||||||
assert image.endswith(".gif") or image.endswith(".png")
|
self.assert_endswith(image, ".gif")
|
||||||
|
|
||||||
def test_mbid(self) -> None:
|
def test_mbid(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import TestPyLastWithLastFm
|
from .test_pylast import TestPyLastWithLastFm
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import TestPyLastWithLastFm
|
from .test_pylast import TestPyLastWithLastFm
|
||||||
|
@ -18,7 +16,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm):
|
||||||
representation = repr(library)
|
representation = repr(library)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert representation.startswith("pylast.Library(")
|
self.assert_startswith(representation, "pylast.Library(")
|
||||||
|
|
||||||
def test_str(self) -> None:
|
def test_str(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
@ -28,7 +26,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm):
|
||||||
string = str(library)
|
string = str(library)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert string.endswith("'s Library")
|
self.assert_endswith(string, "'s Library")
|
||||||
|
|
||||||
def test_library_is_hashable(self) -> None:
|
def test_library_is_hashable(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
|
|
@ -2,17 +2,15 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flaky import flaky
|
from flaky import flaky
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import load_secrets
|
from .test_pylast import PyLastTestCase, load_secrets
|
||||||
|
|
||||||
|
|
||||||
@flaky(max_runs=3, min_passes=1)
|
@flaky(max_runs=3, min_passes=1)
|
||||||
class TestPyLastWithLibreFm:
|
class TestPyLastWithLibreFm(PyLastTestCase):
|
||||||
"""Own class for Libre.fm because we don't need the Last.fm setUp"""
|
"""Own class for Libre.fm because we don't need the Last.fm setUp"""
|
||||||
|
|
||||||
def test_libre_fm(self) -> None:
|
def test_libre_fm(self) -> None:
|
||||||
|
@ -40,4 +38,4 @@ class TestPyLastWithLibreFm:
|
||||||
representation = repr(network)
|
representation = repr(network)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert representation.startswith("pylast.LibreFMNetwork(")
|
self.assert_startswith(representation, "pylast.LibreFMNetwork(")
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
@ -333,12 +330,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
assert len(images) == 4
|
assert len(images) == 4
|
||||||
|
|
||||||
assert images[pylast.SIZE_SMALL].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
|
||||||
assert images[pylast.SIZE_SMALL].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
|
||||||
assert "/34s/" in images[pylast.SIZE_SMALL]
|
assert "/34s/" in images[pylast.SIZE_SMALL]
|
||||||
|
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
|
||||||
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
||||||
|
|
||||||
def test_artist_search(self) -> None:
|
def test_artist_search(self) -> None:
|
||||||
|
@ -365,12 +362,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
assert len(images) == 5
|
assert len(images) == 5
|
||||||
|
|
||||||
assert images[pylast.SIZE_SMALL].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
|
||||||
assert images[pylast.SIZE_SMALL].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
|
||||||
assert "/34s/" in images[pylast.SIZE_SMALL]
|
assert "/34s/" in images[pylast.SIZE_SMALL]
|
||||||
|
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
|
||||||
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
||||||
|
|
||||||
def test_track_search(self) -> None:
|
def test_track_search(self) -> None:
|
||||||
|
@ -399,12 +396,12 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
|
||||||
# Assert
|
# Assert
|
||||||
assert len(images) == 4
|
assert len(images) == 4
|
||||||
|
|
||||||
assert images[pylast.SIZE_SMALL].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
|
||||||
assert images[pylast.SIZE_SMALL].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
|
||||||
assert "/34s/" in images[pylast.SIZE_SMALL]
|
assert "/34s/" in images[pylast.SIZE_SMALL]
|
||||||
|
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
|
self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
|
||||||
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
|
self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
|
||||||
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
|
||||||
|
|
||||||
def test_search_get_total_result_count(self) -> None:
|
def test_search_get_total_result_count(self) -> None:
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
@ -34,17 +32,25 @@ def load_secrets(): # pragma: no cover
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
class PyLastTestCase:
|
||||||
|
def assert_startswith(self, s, prefix, start=None, end=None) -> None:
|
||||||
|
assert s.startswith(prefix, start, end)
|
||||||
|
|
||||||
|
def assert_endswith(self, s, suffix, start=None, end=None) -> None:
|
||||||
|
assert s.endswith(suffix, start, end)
|
||||||
|
|
||||||
|
|
||||||
def _no_xfail_rerun_filter(err, name, test, plugin) -> bool:
|
def _no_xfail_rerun_filter(err, name, test, plugin) -> bool:
|
||||||
for _ in test.iter_markers(name="xfail"):
|
for _ in test.iter_markers(name="xfail"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter)
|
@flaky(max_runs=3, min_passes=1, rerun_filter=_no_xfail_rerun_filter)
|
||||||
class TestPyLastWithLastFm:
|
class TestPyLastWithLastFm(PyLastTestCase):
|
||||||
|
|
||||||
secrets = None
|
secrets = None
|
||||||
|
|
||||||
@staticmethod
|
def unix_timestamp(self):
|
||||||
def unix_timestamp() -> int:
|
|
||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -65,8 +71,7 @@ class TestPyLastWithLastFm:
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def helper_is_thing_hashable(self, thing) -> None:
|
||||||
def helper_is_thing_hashable(thing) -> None:
|
|
||||||
# Arrange
|
# Arrange
|
||||||
things = set()
|
things = set()
|
||||||
|
|
||||||
|
@ -77,8 +82,7 @@ class TestPyLastWithLastFm:
|
||||||
assert thing is not None
|
assert thing is not None
|
||||||
assert len(things) == 1
|
assert len(things) == 1
|
||||||
|
|
||||||
@staticmethod
|
def helper_validate_results(self, a, b, c) -> None:
|
||||||
def helper_validate_results(a, b, c) -> None:
|
|
||||||
# Assert
|
# Assert
|
||||||
assert a is not None
|
assert a is not None
|
||||||
assert b is not None
|
assert b is not None
|
||||||
|
@ -102,31 +106,27 @@ class TestPyLastWithLastFm:
|
||||||
# Assert
|
# Assert
|
||||||
self.helper_validate_results(result1, result2, result3)
|
self.helper_validate_results(result1, result2, result3)
|
||||||
|
|
||||||
@staticmethod
|
def helper_at_least_one_thing_in_top_list(self, things, expected_type) -> None:
|
||||||
def helper_at_least_one_thing_in_top_list(things, expected_type) -> None:
|
|
||||||
# Assert
|
# Assert
|
||||||
assert len(things) > 1
|
assert len(things) > 1
|
||||||
assert isinstance(things, list)
|
assert isinstance(things, list)
|
||||||
assert isinstance(things[0], pylast.TopItem)
|
assert isinstance(things[0], pylast.TopItem)
|
||||||
assert isinstance(things[0].item, expected_type)
|
assert isinstance(things[0].item, expected_type)
|
||||||
|
|
||||||
@staticmethod
|
def helper_only_one_thing_in_top_list(self, things, expected_type) -> None:
|
||||||
def helper_only_one_thing_in_top_list(things, expected_type) -> None:
|
|
||||||
# Assert
|
# Assert
|
||||||
assert len(things) == 1
|
assert len(things) == 1
|
||||||
assert isinstance(things, list)
|
assert isinstance(things, list)
|
||||||
assert isinstance(things[0], pylast.TopItem)
|
assert isinstance(things[0], pylast.TopItem)
|
||||||
assert isinstance(things[0].item, expected_type)
|
assert isinstance(things[0].item, expected_type)
|
||||||
|
|
||||||
@staticmethod
|
def helper_only_one_thing_in_list(self, things, expected_type) -> None:
|
||||||
def helper_only_one_thing_in_list(things, expected_type) -> None:
|
|
||||||
# Assert
|
# Assert
|
||||||
assert len(things) == 1
|
assert len(things) == 1
|
||||||
assert isinstance(things, list)
|
assert isinstance(things, list)
|
||||||
assert isinstance(things[0], expected_type)
|
assert isinstance(things[0], expected_type)
|
||||||
|
|
||||||
@staticmethod
|
def helper_two_different_things_in_top_list(self, things, expected_type) -> None:
|
||||||
def helper_two_different_things_in_top_list(things, expected_type) -> None:
|
|
||||||
# Assert
|
# Assert
|
||||||
assert len(things) == 2
|
assert len(things) == 2
|
||||||
thing1 = things[0]
|
thing1 = things[0]
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pylast
|
import pylast
|
||||||
|
|
||||||
from .test_pylast import TestPyLastWithLastFm
|
from .test_pylast import TestPyLastWithLastFm
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -112,7 +110,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
|
||||||
|
|
||||||
def test_track_get_duration(self) -> None:
|
def test_track_get_duration(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
track = pylast.Track("Daft Punk", "Something About Us", self.network)
|
track = pylast.Track("Radiohead", "Creep", self.network)
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
duration = track.get_duration()
|
duration = track.get_duration()
|
||||||
|
@ -138,7 +136,11 @@ class TestPyLastTrack(TestPyLastWithLastFm):
|
||||||
similar = track.get_similar()
|
similar = track.get_similar()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
found = any(str(track.item) == "Cher - Strong Enough" for track in similar)
|
found = False
|
||||||
|
for track in similar:
|
||||||
|
if str(track.item) == "Madonna - Vogue":
|
||||||
|
found = True
|
||||||
|
break
|
||||||
assert found
|
assert found
|
||||||
|
|
||||||
def test_track_get_similar_limits(self) -> None:
|
def test_track_get_similar_limits(self) -> None:
|
||||||
|
|
|
@ -2,8 +2,6 @@
|
||||||
"""
|
"""
|
||||||
Integration (not unit) tests for pylast.py
|
Integration (not unit) tests for pylast.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import calendar
|
import calendar
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import inspect
|
import inspect
|
||||||
|
@ -26,7 +24,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
representation = repr(user)
|
representation = repr(user)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert representation.startswith("pylast.User('RJ',")
|
self.assert_startswith(representation, "pylast.User('RJ',")
|
||||||
|
|
||||||
def test_str(self) -> None:
|
def test_str(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
@ -347,7 +345,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
url = user.get_image()
|
url = user.get_image()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert url.startswith("https://")
|
self.assert_startswith(url, "https://")
|
||||||
|
|
||||||
def test_user_get_library(self) -> None:
|
def test_user_get_library(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
@ -430,8 +428,8 @@ class TestPyLastUser(TestPyLastWithLastFm):
|
||||||
image = user.get_image()
|
image = user.get_image()
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert image.startswith("https://")
|
self.assert_startswith(image, "https://")
|
||||||
assert image.endswith(".png")
|
self.assert_endswith(image, ".png")
|
||||||
|
|
||||||
def test_get_url(self) -> None:
|
def test_get_url(self) -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -27,7 +25,7 @@ def test_get_cache_key(artist) -> None:
|
||||||
|
|
||||||
@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())])
|
@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())])
|
||||||
def test_cast_and_hash(obj) -> None:
|
def test_cast_and_hash(obj) -> None:
|
||||||
assert isinstance(str(obj), str)
|
assert type(str(obj)) is str
|
||||||
assert isinstance(hash(obj), int)
|
assert isinstance(hash(obj), int)
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,7 +45,7 @@ def test_cast_and_hash(obj) -> None:
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None:
|
def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None:
|
||||||
assert pylast._remove_invalid_xml_chars(test_input) == expected
|
assert pylast.utils._remove_invalid_xml_chars(test_input) == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
25
tox.ini
25
tox.ini
|
@ -1,35 +1,26 @@
|
||||||
[tox]
|
[tox]
|
||||||
requires =
|
envlist =
|
||||||
tox>=4.2
|
|
||||||
env_list =
|
|
||||||
lint
|
lint
|
||||||
py{py3, 313, 312, 311, 310, 39, 38}
|
py{py3, 311, 310, 39, 38, 37}
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
extras =
|
passenv =
|
||||||
tests
|
|
||||||
pass_env =
|
|
||||||
FORCE_COLOR
|
FORCE_COLOR
|
||||||
PYLAST_API_KEY
|
PYLAST_API_KEY
|
||||||
PYLAST_API_SECRET
|
PYLAST_API_SECRET
|
||||||
PYLAST_PASSWORD_HASH
|
PYLAST_PASSWORD_HASH
|
||||||
PYLAST_USERNAME
|
PYLAST_USERNAME
|
||||||
|
extras =
|
||||||
|
tests
|
||||||
commands =
|
commands =
|
||||||
{envpython} -m pytest -v -s -W all \
|
pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --cov-report xml --random-order {posargs}
|
||||||
--cov pylast \
|
|
||||||
--cov tests \
|
|
||||||
--cov-report html \
|
|
||||||
--cov-report term-missing \
|
|
||||||
--cov-report xml \
|
|
||||||
--random-order \
|
|
||||||
{posargs}
|
|
||||||
|
|
||||||
[testenv:lint]
|
[testenv:lint]
|
||||||
|
passenv =
|
||||||
|
PRE_COMMIT_COLOR
|
||||||
skip_install = true
|
skip_install = true
|
||||||
deps =
|
deps =
|
||||||
pre-commit
|
pre-commit
|
||||||
pass_env =
|
|
||||||
PRE_COMMIT_COLOR
|
|
||||||
commands =
|
commands =
|
||||||
pre-commit run --all-files --show-diff-on-failure
|
pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue