diff --git a/.editorconfig b/.editorconfig
index 179fd45..b71c07e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,6 +11,7 @@ charset = utf-8
[*.py]
indent_size = 4
indent_style = space
+
trim_trailing_whitespace = true
# Two-space indentation
diff --git a/.github/labels.yml b/.github/labels.yml
index 090914a..38b5fdb 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -91,6 +91,9 @@
- color: b60205
description: Removal of a feature, usually done in major releases
name: removal
+- color: 2d18b2
+ description: "To automatically merge PRs that are ready"
+ name: automerge
- color: 0366d6
description: "For dependencies"
name: dependencies
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index ba26220..b853342 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -22,12 +22,8 @@ categories:
exclude-labels:
- "changelog: skip"
-autolabeler:
- - label: "changelog: skip"
- branch:
- - "/pre-commit-ci-update-config/"
-
template: |
+
$CHANGES
version-resolver:
diff --git a/.github/renovate.json b/.github/renovate.json
deleted file mode 100644
index 2d2f276..0000000
--- a/.github/renovate.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": ["config:base"],
- "labels": ["changelog: skip", "dependencies"],
- "packageRules": [
- {
- "groupName": "github-actions",
- "matchManagers": ["github-actions"],
- "separateMajorMinor": "false"
- }
- ],
- "schedule": ["on the first day of the month"]
-}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8b9a278..1f65cb8 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -2,74 +2,56 @@ name: Deploy
on:
push:
- branches: [main]
- tags: ["*"]
- pull_request:
- branches: [main]
+ branches:
+ - master
release:
types:
- published
- workflow_dispatch:
-
-permissions:
- contents: read
jobs:
- # Always build & lint package.
- build-package:
- name: Build & verify package
- runs-on: ubuntu-latest
+ build:
+ if: github.repository == 'pylast/pylast'
+ runs-on: ubuntu-20.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v2
with:
fetch-depth: 0
- - uses: hynek/build-and-inspect-python-package@v2
-
- # 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
+ - name: Cache
+ uses: actions/cache@v2
with:
- name: Packages
- path: dist
+ path: ~/.cache/pip
+ key: deploy-${{ hashFiles('**/setup.py') }}
+ restore-keys: |
+ deploy-
- - name: Upload package to Test PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
+ - name: Set up Python
+ uses: actions/setup-python@v2
with:
- repository-url: https://test.pypi.org/legacy/
+ python-version: 3.9
- # Upload to real PyPI on GitHub Releases.
- 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
+ - name: Install dependencies
+ run: |
+ python -m pip install -U pip
+ python -m pip install -U setuptools twine wheel
- permissions:
- id-token: write
+ - name: Build package
+ run: |
+ python setup.py --version
+ python setup.py sdist --format=gztar bdist_wheel
+ twine check dist/*
- steps:
- - name: Download packages built by build-and-inspect-python-package
- uses: actions/download-artifact@v4
+ - name: Publish package to PyPI
+ if: github.event.action == 'published'
+ uses: pypa/gh-action-pypi-publish@master
with:
- name: Packages
- path: dist
+ user: __token__
+ password: ${{ secrets.pypi_password }}
- - name: Upload package to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
+ - name: Publish package to TestPyPI
+ uses: pypa/gh-action-pypi-publish@master
+ with:
+ user: __token__
+ password: ${{ secrets.test_pypi_password }}
+ repository_url: https://test.pypi.org/legacy/
diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml
index 859c948..e84c13e 100644
--- a/.github/workflows/labels.yml
+++ b/.github/workflows/labels.yml
@@ -1,21 +1,15 @@
name: Sync labels
-
-permissions:
- pull-requests: write
-
on:
push:
branches:
- - main
+ - master
paths:
- .github/labels.yml
- workflow_dispatch:
-
jobs:
- sync:
+ build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v2
- uses: micnncim/action-label-syncer@v1
with:
prune: false
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d553e49..f092b74 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,22 +1,12 @@
name: Lint
-on: [push, pull_request, workflow_dispatch]
-
-env:
- FORCE_COLOR: 1
- PIP_DISABLE_PIP_VERSION_CHECK: 1
-
-permissions:
- contents: read
+on: [push, pull_request]
jobs:
lint:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with:
- python-version: "3.x"
- cache: pip
- - uses: pre-commit/action@v3.0.1
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ - uses: pre-commit/action@v2.0.0
diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml
index 0910f73..f1d92f9 100644
--- a/.github/workflows/release-drafter.yml
+++ b/.github/workflows/release-drafter.yml
@@ -4,31 +4,14 @@ on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- - 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:
-
-permissions:
- contents: read
+ - master
jobs:
update_release_draft:
- 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
+ if: github.repository == 'pylast/pylast'
runs-on: ubuntu-latest
steps:
- # Drafts your next release notes as pull requests are merged into "main"
- - uses: release-drafter/release-drafter@v6
+ # Drafts your next release notes as pull requests are merged into "master"
+ - uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/require-pr-label.yml b/.github/workflows/require-pr-label.yml
deleted file mode 100644
index 0d910db..0000000
--- a/.github/workflows/require-pr-label.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: Require PR label
-
-on:
- pull_request:
- types: [opened, reopened, labeled, unlabeled, synchronize]
-
-jobs:
- label:
- runs-on: ubuntu-latest
-
- permissions:
- issues: write
- pull-requests: write
-
- steps:
- - uses: mheap/github-action-required-labels@v5
- with:
- mode: minimum
- count: 1
- labels:
- "changelog: Added, changelog: Changed, changelog: Deprecated, changelog:
- Fixed, changelog: Removed, changelog: Security, changelog: skip"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7f09cba..e8b978a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,28 +1,44 @@
name: Test
-on: [push, pull_request, workflow_dispatch]
+on: [push, pull_request]
env:
FORCE_COLOR: 1
jobs:
- test:
+ build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
- os: [ubuntu-latest]
+ 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@v4
+ - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- allow-prereleases: true
- cache: pip
+
+ - 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: |
@@ -40,15 +56,7 @@ jobs:
PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }}
- name: Upload coverage
- uses: codecov/codecov-action@v3.1.5
+ uses: codecov/codecov-action@v1
with:
- flags: ${{ matrix.os }}
+ flags: ${{ matrix.codecov-flag }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
-
- success:
- needs: test
- runs-on: ubuntu-latest
- name: Test successful
- steps:
- - name: Success
- run: echo Test successful
diff --git a/.mergify.yml b/.mergify.yml
new file mode 100644
index 0000000..dad8639
--- /dev/null
+++ b/.mergify.yml
@@ -0,0 +1,8 @@
+pull_request_rules:
+ - name: Automatic merge on approval
+ conditions:
+ - label=automerge
+ - status-success=build
+ actions:
+ merge:
+ method: merge
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 477419b..a363863 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,74 +1,49 @@
repos:
- - repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.5.0
+ - repo: https://github.com/asottile/pyupgrade
+ rev: v2.10.0
hooks:
- - id: ruff
- args: [--exit-non-zero-on-fix]
+ - id: pyupgrade
+ args: ["--py36-plus"]
- - repo: https://github.com/psf/black-pre-commit-mirror
- rev: 24.4.2
+ - repo: https://github.com/psf/black
+ rev: 20.8b1
hooks:
- id: black
+ args: ["--target-version", "py36"]
+ # override until resolved: https://github.com/psf/black/issues/402
+ files: \.pyi?$
+ types: []
- repo: https://github.com/asottile/blacken-docs
- rev: 1.18.0
+ rev: v1.9.2
hooks:
- id: blacken-docs
- args: [--target-version=py38]
- additional_dependencies: [black]
+ args: ["--target-version", "py36"]
+ additional_dependencies: [black==20.8b1]
- - repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.6.0
+ - repo: https://github.com/PyCQA/isort
+ rev: 5.7.0
hooks:
- - id: check-added-large-files
- - id: check-case-conflict
- - id: check-merge-conflict
- - id: check-json
- - id: check-toml
- - 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
+ - id: isort
- - repo: https://github.com/python-jsonschema/check-jsonschema
- rev: 0.28.6
+ - repo: https://gitlab.com/pycqa/flake8
+ rev: 3.8.4
hooks:
- - id: check-github-workflows
- - id: check-renovate
+ - id: flake8
+ additional_dependencies: [flake8-2020, flake8-implicit-str-concat]
- - repo: https://github.com/rhysd/actionlint
+ - repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.7.1
hooks:
- - id: actionlint
+ - id: python-check-blanket-noqa
- - repo: https://github.com/tox-dev/pyproject-fmt
- rev: 2.1.3
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v3.4.0
hooks:
- - id: pyproject-fmt
-
- - repo: https://github.com/abravalheri/validate-pyproject
- rev: v0.18
- hooks:
- - id: validate-pyproject
+ - id: check-merge-conflict
+ - id: check-yaml
- repo: https://github.com/tox-dev/tox-ini-fmt
- rev: 1.3.1
+ rev: 0.5.0
hooks:
- 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:
- autoupdate_schedule: quarterly
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
new file mode 100644
index 0000000..43dbfa3
--- /dev/null
+++ b/.scrutinizer.yml
@@ -0,0 +1,9 @@
+checks:
+ python:
+ code_rating: true
+ duplicate_code: true
+filter:
+ excluded_paths:
+ - '*/test/*'
+tools:
+ external_code_coverage: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d424974..0b4ede3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,136 +1,125 @@
# Changelog
-This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+All notable changes to this project will be documented in this file.
-## 4.2.1 and newer
-
-See GitHub Releases:
-
-- https://github.com/pylast/pylast/releases
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.2.0] - 2021-03-14
## Changed
-- Fix unsafe creation of temp file for caching, and improve exception raising (#356)
- @kvanzuijlen
-- [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci
+* Fix unsafe creation of temp file for caching, and improve exception raising (#356) @kvanzuijlen
+* [pre-commit.ci] pre-commit autoupdate (#362) @pre-commit-ci
+
## [4.1.0] - 2021-01-04
-
## Added
-- Add support for streaming (#336) @kvanzuijlen
-- Add Python 3.9 final to Travis CI (#350) @sheetalsingala
+* Add support for streaming (#336) @kvanzuijlen
+* Add Python 3.9 final to Travis CI (#350) @sheetalsingala
## Changed
-- Update copyright year (#360) @hugovk
-- Replace Travis CI with GitHub Actions (#352) @hugovk
-- [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci
+* Update copyright year (#360) @hugovk
+* Replace Travis CI with GitHub Actions (#352) @hugovk
+* [pre-commit.ci] pre-commit autoupdate (#359) @pre-commit-ci
## 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
-
## Added
-- Add support for Python 3.9 (#347) @hugovk
+* 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
+* Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and `STATUS_TOKEN_ERROR` (#348) @hugovk
+* Drop support for EOL Python 3.5 (#346) @hugovk
+
## [3.3.0] - 2020-06-25
-
### Added
-- `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
-- Improve handling of error responses from the API (#327) @spiritualized
+* Improve handling of error responses from the API (#327) @spiritualized
### Deprecated
-- Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332)
- @hugovk
+* Deprecate `Artist.get_cover_image`, they're no longer available from Last.fm (#332) @hugovk
### 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
-
### Fixed
-- Only Python 3 is supported: don't create universal wheel (#318) @hugovk
-- Fix regression calling `get_recent_tracks` with `limit=None` (#320) @hugovk
-- Fix `DeprecationWarning`: Please use `assertRegex` instead (#323) @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 `DeprecationWarning`: Please use `assertRegex` instead (#323) @hugovk
## [3.2.0] - 2020-01-03
-
### Added
-- Support for Python 3.8
-- Store album art URLs when you call `GetTopAlbums` ([#307])
-- Retry paging through results on exception ([#297])
-- More error status codes from https://last.fm/api/errorcodes ([#297])
+* Support for Python 3.8
+* Store album art URLs when you call `GetTopAlbums` ([#307])
+* Retry paging through results on exception ([#297])
+* More error status codes from https://last.fm/api/errorcodes ([#297])
### Changed
-- Respect `get_recent_tracks`' limit when there's a now playing track ([#310])
-- Move installable code to `src/` ([#301])
-- Update `get_weekly_artist_charts` docstring: only for `User` ([#311])
-- Remove Python 2 warnings, `python_requires` should be enough ([#312])
-- Use setuptools_scm to simplify versioning during release ([#316])
-- Various lint and test updates
+* Respect `get_recent_tracks`' limit when there's a now playing track ([#310])
+* Move installable code to `src/` ([#301])
+* Update `get_weekly_artist_charts` docstring: only for `User` ([#311])
+* Remove Python 2 warnings, `python_requires` should be enough ([#312])
+* Use setuptools_scm to simplify versioning during release ([#316])
+* Various lint and test updates
### 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
supported" error when calling it. A future version of pylast will remove its
`User.get_artist_tracks` altogether. ([#305])
-- `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version. Use
- `STATUS_OPERATION_FAILED` instead.
+* `STATUS_TOKEN_ERROR` is deprecated and will be removed in a future version.
+ Use `STATUS_OPERATION_FAILED` instead.
## [3.1.0] - 2019-03-07
-
### Added
-- Extract username from session via new
+* Extract username from session via new
`SessionKeyGenerator.get_web_auth_session_key_username` ([#290])
-- `User.get_track_scrobbles` ([#298])
+* `User.get_track_scrobbles` ([#298])
### Deprecated
-- `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement.
- ([#298])
+* `User.get_artist_tracks`. Use `User.get_track_scrobbles` as a partial replacement.
+ ([#298])
## [3.0.0] - 2019-01-01
-
### Added
-
-- This changelog file ([#273])
+* This changelog file ([#273])
### Removed
-- Support for Python 2.7 ([#265])
+* Support for Python 2.7 ([#265])
-- Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE` and
- `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282])
+* Constants `COVER_SMALL`, `COVER_MEDIUM`, `COVER_LARGE`, `COVER_EXTRA_LARGE`
+ and `COVER_MEGA`. Use `SIZE_SMALL` etc. instead. ([#282])
## [2.4.0] - 2018-08-08
-
### 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.1.0]: https://github.com/pylast/pylast/compare/4.0.0...4.1.0
diff --git a/COPYING b/COPYING
index 5b651ea..c4ff845 100644
--- a/COPYING
+++ b/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:
-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.
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.
diff --git a/README.md b/README.md
index c22fbec..fb05c3b 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,12 @@
-# pyLast
+pyLast
+======
[](https://pypi.org/project/pylast/)
[](https://pypi.org/project/pylast/)
[](https://pypistats.org/packages/pylast)
[](https://github.com/pylast/pylast/actions)
-[](https://codecov.io/gh/pylast/pylast)
-[](https://github.com/psf/black)
+[](https://codecov.io/gh/pylast/pylast)
+[](https://github.com/psf/black)
[](https://zenodo.org/badge/latestdoi/7803088)
A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites
@@ -13,48 +14,53 @@ such as [Libre.fm](https://libre.fm/).
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:
```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:
```txt
--e https://git.hirad.it/Hirad/pylast#egg=pylast
+-e git://github.com/pylast/pylast.git#egg=pylast
```
Note:
-- pyLast 5.3+ supports Python 3.8-3.13.
-- pyLast 5.2+ supports Python 3.8-3.12.
-- pyLast 5.1 supports Python 3.7-3.11.
-- pyLast 5.0 supports Python 3.7-3.10.
-- pyLast 4.3 - 4.5 supports Python 3.6-3.10.
-- pyLast 4.0 - 4.2 supports Python 3.6-3.9.
-- pyLast 3.2 - 3.3 supports Python 3.5-3.8.
-- pyLast 3.0 - 3.1 supports Python 3.5-3.7.
-- pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7.
-- pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6.
-- pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6.
-- 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.
+* pyLast 4.0+ supports Python 3.6-3.9.
+* pyLast 3.2 - 3.3 supports Python 3.5-3.8.
+* pyLast 3.0 - 3.1 supports Python 3.5-3.7.
+* pyLast 2.2 - 2.4 supports Python 2.7.10+, 3.4-3.7.
+* pyLast 2.0 - 2.1 supports Python 2.7.10+, 3.4-3.6.
+* pyLast 1.7 - 1.9 supports Python 2.7, 3.3-3.6.
+* 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.
-- Access to all the data exposed by the Last.fm web services.
-- Scrobbling support.
-- Full object-oriented design.
-- Proxy support.
-- Internal caching support for some web services calls (disabled by default).
-- Support for other API-compatible networks like Libre.fm.
+ * Simple public interface.
+ * Access to all the data exposed by the Last.fm web services.
+ * Scrobbling support.
+ * Full object-oriented design.
+ * Proxy support.
+ * Internal caching support for some web services calls (disabled by default).
+ * Support for other API-compatible networks like Libre.fm.
-## Getting started
+
+Getting started
+---------------
Here's some simple code example to get you started. In order to create any object from
pyLast, you need a `Network` object which represents a social music network that is
@@ -79,44 +85,12 @@ network = pylast.LastFMNetwork(
username=username,
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
+artist = network.get_artist("System of a Down")
+artist.shout("<3")
+
+
track = network.get_track("Iron Maiden", "The Nomad")
track.love()
track.add_tags(("awesome", "favorite"))
@@ -127,18 +101,18 @@ track.add_tags(("awesome", "favorite"))
More examples in
hugovk/lastfm-tools and
-[tests/](https://github.com/pylast/pylast/tree/main/tests).
+[tests/](https://github.com/pylast/pylast/tree/master/tests).
-## Testing
+Testing
+-------
-The [tests/](https://github.com/pylast/pylast/tree/main/tests) directory contains
+The [tests/](https://github.com/pylast/pylast/tree/master/tests) directory contains
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
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)
-to test_pylast.yaml and fill out the credentials, or set them as environment variables
-like:
+[example_test_pylast.yaml](example_test_pylast.yaml) to test_pylast.yaml and fill out
+the credentials, or set them as environment variables like:
```sh
export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
@@ -169,7 +143,8 @@ coverage html # for HTML report
open htmlcov/index.html
```
-## Logging
+Logging
+-------
To enable from your own code:
@@ -177,8 +152,7 @@ To enable from your own code:
import logging
import pylast
-logging.basicConfig(level=logging.INFO)
-
+logging.basicConfig(level=logging.DEBUG)
network = pylast.LastFMNetwork(...)
```
@@ -186,8 +160,5 @@ network = pylast.LastFMNetwork(...)
To enable from pytest:
```sh
-pytest --log-cli-level info -k test_album_search_images
+pytest --log-cli-level debug -k test_album_search_images
```
-
-To also see data returned from the API, use `level=logging.DEBUG` or
-`--log-cli-level debug` instead.
diff --git a/RELEASING.md b/RELEASING.md
index 9b2e38a..7e3cdfc 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,22 +1,23 @@
# Release Checklist
-- [ ] Get `main` to the appropriate code release state.
- [GitHub Actions](https://github.com/pylast/pylast/actions) should be running
- cleanly for all merges to `main`.
+* [ ] Get master to the appropriate code release state.
+ [GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for
+ 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
-- [ ] Check next tag is correct, amend if needed
+* [ ] Check next tag is correct, amend if needed
-- [ ] Publish release
+* [ ] Copy text into [`CHANGELOG.md`](CHANGELOG.md)
-- [ ] Check the tagged
- [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
+* [ ] Publish release
+
+* [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions?query=workflow%3ADeploy)
has deployed to [PyPI](https://pypi.org/project/pylast/#history)
-- [ ] Check installation:
+* [ ] Check installation:
```bash
pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)"
diff --git a/example_test_pylast.yaml b/example_test_pylast.yaml
index 00b09f1..a8fa045 100644
--- a/example_test_pylast.yaml
+++ b/example_test_pylast.yaml
@@ -1,4 +1,4 @@
-username: TODO_ENTER_YOURS_HERE
-password_hash: TODO_ENTER_YOURS_HERE
-api_key: TODO_ENTER_YOURS_HERE
-api_secret: TODO_ENTER_YOURS_HERE
+username: TODO_ENTER_YOURS_HERE
+password_hash: TODO_ENTER_YOURS_HERE
+api_key: TODO_ENTER_YOURS_HERE
+api_secret: TODO_ENTER_YOURS_HERE
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 0586bb1..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -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 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"
diff --git a/pytest.ini b/pytest.ini
index 3f83bd3..34667c8 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -2,5 +2,3 @@
filterwarnings =
once::DeprecationWarning
once::PendingDeprecationWarning
-
-xfail_strict=true
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..191fac9
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[flake8]
+max_line_length = 88
+
+[tool:isort]
+profile = black
diff --git a/setup.py b/setup.py
new file mode 100755
index 0000000..a2d891f
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,46 @@
+from setuptools import find_packages, setup
+
+with open("README.md") as f:
+ long_description = f.read()
+
+
+def local_scheme(version):
+ """Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2)
+ to be able to upload to Test PyPI"""
+ return ""
+
+
+setup(
+ name="pylast",
+ description="A Python interface to Last.fm and Libre.fm",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ author="Amr Hassan and Contributors",
+ author_email="amr.hassan@gmail.com",
+ url="https://github.com/pylast/pylast",
+ license="Apache2",
+ keywords=["Last.fm", "music", "scrobble", "scrobbling"],
+ packages=find_packages(where="src"),
+ package_dir={"": "src"},
+ use_scm_version={"local_scheme": local_scheme},
+ setup_requires=["setuptools_scm"],
+ extras_require={
+ "tests": ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"]
+ },
+ python_requires=">=3.6",
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "License :: OSI Approved :: Apache Software License",
+ "Topic :: Internet",
+ "Topic :: Multimedia :: Sound/Audio",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Programming Language :: Python :: Implementation :: PyPy",
+ ],
+)
diff --git a/src/pylast/__init__.py b/src/pylast/__init__.py
index bd856bc..317de7e 100644
--- a/src/pylast/__init__.py
+++ b/src/pylast/__init__.py
@@ -1,6 +1,6 @@
#
# 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 2013-2021 hugovk
@@ -18,30 +18,28 @@
# limitations under the License.
#
# https://github.com/pylast/pylast
-from __future__ import annotations
import collections
import hashlib
import html.entities
-import importlib.metadata
import logging
import os
-import re
import shelve
import ssl
import tempfile
import time
import xml.dom
+from http.client import HTTPSConnection
from urllib.parse import quote_plus
from xml.dom import Node, minidom
-import httpx
+import pkg_resources
__author__ = "Amr Hassan, hugovk, Mice Pápai"
__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai"
__license__ = "apache2"
__email__ = "amr.hassan@gmail.com"
-__version__ = importlib.metadata.version(__name__)
+__version__ = pkg_resources.get_distribution(__name__).version
# 1 : This error does not exist
@@ -121,12 +119,6 @@ DELAY_TIME = 0.2
# Python >3.4 has sane defaults
SSL_CONTEXT = ssl.create_default_context()
-HEADERS = {
- "Content-type": "application/x-www-form-urlencoded",
- "Accept-Charset": "utf-8",
- "User-Agent": f"pylast/{__version__}",
-}
-
logger = logging.getLogger(__name__)
logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -150,7 +142,7 @@ class _Network:
domain_names,
urls,
token=None,
- ) -> None:
+ ):
"""
name: the name of the network
homepage: the homepage URL
@@ -189,8 +181,9 @@ class _Network:
self.urls = urls
self.cache_backend = None
+ self.proxy_enabled = False
self.proxy = None
- self.last_call_time: float = 0.0
+ self.last_call_time = 0
self.limit_rate = False
# Load session_key and username from authentication token if provided
@@ -209,8 +202,8 @@ class _Network:
sk_gen = SessionKeyGenerator(self)
self.session_key = sk_gen.get_session_key(self.username, self.password_hash)
- def __str__(self) -> str:
- return f"{self.name} Network"
+ def __str__(self):
+ return "%s Network" % self.name
def get_artist(self, artist_name):
"""
@@ -269,8 +262,10 @@ class _Network:
if domain_language in self.domain_names:
return self.domain_names[domain_language]
- def _get_url(self, domain, url_type) -> str:
- return f"https://{self._get_language_domain(domain)}/{self.urls[url_type]}"
+ def _get_url(self, domain, url_type):
+ return "https://{}/{}".format(
+ self._get_language_domain(domain), self.urls[url_type]
+ )
def _get_ws_auth(self):
"""
@@ -278,7 +273,7 @@ class _Network:
"""
return self.api_key, self.api_secret, self.session_key
- def _delay_call(self) -> None:
+ def _delay_call(self):
"""
Makes sure that web service calls are at least 0.2 seconds apart.
"""
@@ -291,7 +286,7 @@ class _Network:
self.last_call_time = now
- def get_top_artists(self, limit=None, cacheable: bool = True):
+ def get_top_artists(self, limit=None, cacheable=True):
"""Returns the most played artists as a sequence of TopItem objects."""
params = {}
@@ -302,7 +297,7 @@ class _Network:
return _extract_top_artists(doc, self)
- def get_top_tracks(self, limit=None, cacheable: bool = True):
+ def get_top_tracks(self, limit=None, cacheable=True):
"""Returns the most played tracks as a sequence of TopItem objects."""
params = {}
@@ -321,14 +316,14 @@ class _Network:
return seq
- def get_top_tags(self, limit=None, cacheable: bool = True):
+ def get_top_tags(self, limit=None, cacheable=True):
"""Returns the most used tags as a sequence of TopItem objects."""
# Last.fm has no "limit" parameter for tag.getTopTags
# so we need to get all (250) and then limit locally
doc = _Request(self, "tag.getTopTags").execute(cacheable)
- seq: list[TopItem] = []
+ seq = []
for node in doc.getElementsByTagName("tag"):
if limit and len(seq) >= limit:
break
@@ -338,7 +333,7 @@ class _Network:
return seq
- def get_geo_top_artists(self, country, limit=None, cacheable: bool = True):
+ def get_geo_top_artists(self, country, limit=None, cacheable=True):
"""Get the most popular artists on Last.fm by country.
Parameters:
country (Required) : A country name, as defined by the ISO 3166-1
@@ -355,9 +350,7 @@ class _Network:
return _extract_top_artists(doc, self)
- def get_geo_top_tracks(
- self, country, location=None, limit=None, cacheable: bool = True
- ):
+ def get_geo_top_tracks(self, country, location=None, limit=None, cacheable=True):
"""Get the most popular tracks on Last.fm last week by country.
Parameters:
country (Required) : A country name, as defined by the ISO 3166-1
@@ -388,67 +381,80 @@ class _Network:
return seq
- def enable_proxy(self, proxy: str | dict) -> None:
- """Enable default web proxy.
- Multiple proxies can be passed as a `dict`, see
- https://www.python-httpx.org/advanced/#http-proxying
- """
- self.proxy = proxy
+ def enable_proxy(self, host, port):
+ """Enable a default web proxy"""
- def disable_proxy(self) -> None:
+ self.proxy = [host, _number(port)]
+ self.proxy_enabled = True
+
+ def disable_proxy(self):
"""Disable using the web proxy"""
- self.proxy = None
- def is_proxy_enabled(self) -> bool:
- """Returns True if web proxy is enabled."""
- return self.proxy is not None
+ self.proxy_enabled = False
- def enable_rate_limit(self) -> None:
+ def is_proxy_enabled(self):
+ """Returns True if a web proxy is enabled."""
+
+ return self.proxy_enabled
+
+ def _get_proxy(self):
+ """Returns proxy details."""
+
+ return self.proxy
+
+ def enable_rate_limit(self):
"""Enables rate limiting for this network"""
self.limit_rate = True
- def disable_rate_limit(self) -> None:
+ def disable_rate_limit(self):
"""Disables rate limiting for this network"""
self.limit_rate = False
- def is_rate_limited(self) -> bool:
+ def is_rate_limited(self):
"""Return True if web service calls are rate limited"""
return self.limit_rate
- def enable_caching(self, file_path=None) -> None:
+ def enable_caching(self, file_path=None):
"""Enables caching request-wide for all cacheable calls.
* file_path: A file path for the backend storage file. If
None set, a temp file would probably be created, according the backend.
"""
+
if not file_path:
self.cache_backend = _ShelfCacheBackend.create_shelf()
return
self.cache_backend = _ShelfCacheBackend(file_path)
- def disable_caching(self) -> None:
+ def disable_caching(self):
"""Disables all caching features."""
+
self.cache_backend = None
- def is_caching_enabled(self) -> bool:
+ def is_caching_enabled(self):
"""Returns True if caching is enabled."""
- return self.cache_backend is not None
+
+ return not (self.cache_backend is None)
+
+ def _get_cache_backend(self):
+
+ return self.cache_backend
def search_for_album(self, album_name):
- """Searches for an album by its name. Returns an AlbumSearch object.
+ """Searches for an album by its name. Returns a AlbumSearch object.
Use get_next_page() to retrieve sequences of results."""
return AlbumSearch(album_name, self)
def search_for_artist(self, artist_name):
- """Searches for an artist by its name. Returns an ArtistSearch object.
+ """Searches of an artist by its name. Returns a ArtistSearch object.
Use get_next_page() to retrieve sequences of results."""
return ArtistSearch(artist_name, self)
def search_for_track(self, artist_name, track_name):
- """Searches for a track by its name and its artist. Set artist to an
+ """Searches of a track by its name and its artist. Set artist to an
empty string if not available.
Returns a TrackSearch object.
Use get_next_page() to retrieve sequences of results."""
@@ -492,7 +498,7 @@ class _Network:
track_number=None,
mbid=None,
context=None,
- ) -> None:
+ ):
"""
Used to notify Last.fm that a user has started listening to a track.
@@ -529,25 +535,26 @@ class _Network:
def scrobble(
self,
- artist: str,
- title: str,
- timestamp: int,
- album: str | None = None,
- album_artist: str | None = None,
- track_number: int | None = None,
- duration: int | None = None,
- stream_id: str | None = None,
- context: str | None = None,
- mbid: str | None = None,
+ artist,
+ title,
+ timestamp,
+ album=None,
+ album_artist=None,
+ track_number=None,
+ duration=None,
+ stream_id=None,
+ context=None,
+ mbid=None,
):
+
"""Used to add a track-play to a user's profile.
Parameters:
artist (Required) : The artist 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,
- January 1st 1970 UTC).
+ January 1st 1970 UTC). This must be in the UTC time zone.
album (Optional) : The album name.
album_artist (Optional) : The album artist - if this differs from
the track artist.
@@ -578,7 +585,7 @@ class _Network:
)
)
- def scrobble_many(self, tracks) -> None:
+ def scrobble_many(self, tracks):
"""
Used to scrobble a batch of tracks at once. The parameter tracks is a
sequence of dicts per track containing the keyword arguments as if
@@ -593,8 +600,9 @@ class _Network:
params = {}
for i in range(len(tracks_to_scrobble)):
- params[f"artist[{i}]"] = tracks_to_scrobble[i]["artist"]
- params[f"track[{i}]"] = tracks_to_scrobble[i]["title"]
+
+ params["artist[%d]" % i] = tracks_to_scrobble[i]["artist"]
+ params["track[%d]" % i] = tracks_to_scrobble[i]["title"]
additional_args = (
"timestamp",
@@ -613,13 +621,14 @@ class _Network:
}
for arg in additional_args:
+
if arg in tracks_to_scrobble[i] and tracks_to_scrobble[i][arg]:
if arg in args_map_to:
maps_to = args_map_to[arg]
else:
maps_to = arg
- params[f"{maps_to}[{i}]"] = tracks_to_scrobble[i][arg]
+ params["%s[%d]" % (maps_to, i)] = tracks_to_scrobble[i][arg]
_Request(self, "track.scrobble", params).execute()
@@ -628,6 +637,7 @@ class _Network:
class LastFMNetwork(_Network):
+
"""A Last.fm network object
api_key: a provided API_KEY
@@ -650,13 +660,13 @@ class LastFMNetwork(_Network):
def __init__(
self,
- api_key: str = "",
- api_secret: str = "",
- session_key: str = "",
- username: str = "",
- password_hash: str = "",
- token: str = "",
- ) -> None:
+ api_key="",
+ api_secret="",
+ session_key="",
+ username="",
+ password_hash="",
+ token="",
+ ):
super().__init__(
name="Last.fm",
homepage="https://www.last.fm",
@@ -691,21 +701,23 @@ class LastFMNetwork(_Network):
},
)
- def __repr__(self) -> str:
- return (
- "pylast.LastFMNetwork("
- f"'{self.api_key}', "
- f"'{self.api_secret}', "
- f"'{self.session_key}', "
- f"'{self.username}', "
- f"'{self.password_hash}'"
- ")"
+ def __repr__(self):
+ return "pylast.LastFMNetwork(%s)" % (
+ ", ".join(
+ (
+ "'%s'" % self.api_key,
+ "'%s'" % self.api_secret,
+ "'%s'" % self.session_key,
+ "'%s'" % self.username,
+ "'%s'" % self.password_hash,
+ )
+ )
)
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_secret: a provided API_SECRET
@@ -719,35 +731,31 @@ class LibreFMNetwork(_Network):
"""
def __init__(
- self,
- api_key: str = "",
- api_secret: str = "",
- session_key: str = "",
- username: str = "",
- password_hash: str = "",
- ) -> None:
+ self, api_key="", api_secret="", session_key="", username="", password_hash=""
+ ):
+
super().__init__(
- name="music.lonestar.it",
- homepage="https://music.lonestar.it",
- ws_server=("music.lonestar.it", "/2.0/"),
+ name="Libre.fm",
+ homepage="https://libre.fm",
+ ws_server=("libre.fm", "/2.0/"),
api_key=api_key,
api_secret=api_secret,
session_key=session_key,
username=username,
password_hash=password_hash,
domain_names={
- DOMAIN_ENGLISH: "music.lonestar.it",
- DOMAIN_GERMAN: "music.lonestar.it",
- DOMAIN_SPANISH: "music.lonestar.it",
- DOMAIN_FRENCH: "music.lonestar.it",
- DOMAIN_ITALIAN: "music.lonestar.it",
- DOMAIN_POLISH: "music.lonestar.it",
- DOMAIN_PORTUGUESE: "music.lonestar.it",
- DOMAIN_SWEDISH: "music.lonestar.it",
- DOMAIN_TURKISH: "music.lonestar.it",
- DOMAIN_RUSSIAN: "music.lonestar.it",
- DOMAIN_JAPANESE: "music.lonestar.it",
- DOMAIN_CHINESE: "music.lonestar.it",
+ DOMAIN_ENGLISH: "libre.fm",
+ DOMAIN_GERMAN: "libre.fm",
+ DOMAIN_SPANISH: "libre.fm",
+ DOMAIN_FRENCH: "libre.fm",
+ DOMAIN_ITALIAN: "libre.fm",
+ DOMAIN_POLISH: "libre.fm",
+ DOMAIN_PORTUGUESE: "libre.fm",
+ DOMAIN_SWEDISH: "libre.fm",
+ DOMAIN_TURKISH: "libre.fm",
+ DOMAIN_RUSSIAN: "libre.fm",
+ DOMAIN_JAPANESE: "libre.fm",
+ DOMAIN_CHINESE: "libre.fm",
},
urls={
"album": "artist/%(artist)s/album/%(album)s",
@@ -759,29 +767,31 @@ class LibreFMNetwork(_Network):
},
)
- def __repr__(self) -> str:
- return (
- "pylast.LibreFMNetwork("
- f"'{self.api_key}', "
- f"'{self.api_secret}', "
- f"'{self.session_key}', "
- f"'{self.username}', "
- f"'{self.password_hash}'"
- ")"
+ def __repr__(self):
+ return "pylast.LibreFMNetwork(%s)" % (
+ ", ".join(
+ (
+ "'%s'" % self.api_key,
+ "'%s'" % self.api_secret,
+ "'%s'" % self.session_key,
+ "'%s'" % self.username,
+ "'%s'" % self.password_hash,
+ )
+ )
)
class _ShelfCacheBackend:
"""Used as a backend for caching cacheable requests."""
- def __init__(self, file_path=None, flag=None) -> None:
+ def __init__(self, file_path=None, flag=None):
if flag is not None:
self.shelf = shelve.open(file_path, flag=flag)
else:
self.shelf = shelve.open(file_path)
self.cache_keys = set(self.shelf.keys())
- def __contains__(self, key) -> bool:
+ def __contains__(self, key):
return key in self.cache_keys
def __iter__(self):
@@ -790,7 +800,7 @@ class _ShelfCacheBackend:
def get_xml(self, key):
return self.shelf[key]
- def set_xml(self, key, xml_string) -> None:
+ def set_xml(self, key, xml_string):
self.cache_keys.add(key)
self.shelf[key] = xml_string
@@ -804,8 +814,8 @@ class _ShelfCacheBackend:
class _Request:
"""Representing an abstract web service operation."""
- def __init__(self, network, method_name, params=None) -> None:
- logger.info(method_name)
+ def __init__(self, network, method_name, params=None):
+ logger.debug(method_name)
if params is None:
params = {}
@@ -822,13 +832,13 @@ class _Request:
self.params["method"] = method_name
if network.is_caching_enabled():
- self.cache = network.cache_backend
+ self.cache = network._get_cache_backend()
if self.session_key:
self.params["sk"] = self.session_key
self.sign_it()
- def sign_it(self) -> None:
+ def sign_it(self):
"""Sign this request."""
if "api_sig" not in self.params.keys():
@@ -889,48 +899,79 @@ class _Request:
if self.network.limit_rate:
self.network._delay_call()
- username = self.params.pop("username", None)
- username = "" if username is None else f"?username={username}"
+ data = []
+ for name in self.params.keys():
+ data.append("=".join((name, quote_plus(_string(self.params[name])))))
+ data = "&".join(data)
+ logger.debug(data)
+
+ if "api_sig" in self.params.keys():
+ method = "POST"
+ url_parameters = ""
+ else:
+ method = "GET"
+ url_parameters = "?" + data
+ logger.debug(method)
+
+ headers = {
+ "Content-type": "application/x-www-form-urlencoded",
+ "Accept-Charset": "utf-8",
+ "User-Agent": "pylast/" + __version__,
+ }
(host_name, host_subdir) = self.network.ws_server
- timeout = httpx.Timeout(5, read=10)
if self.network.is_proxy_enabled():
- client = httpx.Client(
- verify=SSL_CONTEXT,
- base_url=f"https://{host_name}",
- headers=HEADERS,
- proxies=self.network.proxy,
- timeout=timeout,
+ conn = HTTPSConnection(
+ context=SSL_CONTEXT,
+ host=self.network._get_proxy()[0],
+ port=self.network._get_proxy()[1],
)
+
+ try:
+ conn.request(
+ method=method,
+ url="https://" + host_name + host_subdir + url_parameters,
+ body=data,
+ headers=headers,
+ )
+ except Exception as e:
+ raise NetworkError(self.network, e) from e
+
else:
- client = httpx.Client(
- verify=SSL_CONTEXT,
- base_url=f"https://{host_name}",
- headers=HEADERS,
- timeout=timeout,
- )
+ conn = HTTPSConnection(context=SSL_CONTEXT, host=host_name)
+
+ try:
+ conn.request(
+ method=method,
+ url=host_subdir + url_parameters,
+ body=data,
+ headers=headers,
+ )
+ except Exception as e:
+ raise NetworkError(self.network, e) from e
try:
- response = client.post(f"{host_subdir}{username}", data=self.params)
+ response = conn.getresponse()
+ if response.status in [500, 502, 503, 504]:
+ raise WSError(
+ self.network,
+ response.status,
+ "Connection to the API failed with HTTP code "
+ + str(response.status),
+ )
+ response_text = _unicode(response.read())
except Exception as e:
- raise NetworkError(self.network, e) from e
-
- if response.status_code in (500, 502, 503, 504):
- raise WSError(
- self.network,
- response.status_code,
- f"Connection to the API failed with HTTP code {response.status_code}",
- )
- response_text = _unicode(response.read())
+ raise MalformedResponseError(self.network, e) from e
try:
self._check_response_for_errors(response_text)
finally:
- client.close()
+ conn.close()
+ logger.debug(response_text)
return response_text
- def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document:
+ def execute(self, cacheable=False):
"""Returns the XML DOM response of the POST Request from the server"""
if self.network.is_caching_enabled() and cacheable:
@@ -938,22 +979,23 @@ class _Request:
else:
response = self._download_response()
- return _parse_response(response)
+ return minidom.parseString(_string(response).replace("opensearch:", ""))
def _check_response_for_errors(self, response):
"""Checks the response for errors and raises one if any exists."""
+
try:
- doc = _parse_response(response)
+ doc = minidom.parseString(_string(response).replace("opensearch:", ""))
except Exception as e:
raise MalformedResponseError(self.network, e) from e
- element = doc.getElementsByTagName("lfm")[0]
- logger.debug(doc.toprettyxml())
+ e = doc.getElementsByTagName("lfm")[0]
+ # logger.debug(doc.toprettyxml())
- if element.getAttribute("status") != "ok":
- element = doc.getElementsByTagName("error")[0]
- status = element.getAttribute("code")
- details = element.firstChild.data.strip()
+ if e.getAttribute("status") != "ok":
+ e = doc.getElementsByTagName("error")[0]
+ status = e.getAttribute("code")
+ details = e.firstChild.data.strip()
raise WSError(self.network, status, details)
@@ -975,13 +1017,13 @@ class SessionKeyGenerator:
A session key's lifetime is infinite, unless the user revokes the rights
of the given API Key.
- If you create a Network object with just an API_KEY and API_SECRET and a
+ If you create a Network object with just a API_KEY and API_SECRET and a
username and a password_hash, a SESSION_KEY will be automatically generated
for that network and stored in it so you don't have to do this manually,
unless you want to.
"""
- def __init__(self, network) -> None:
+ def __init__(self, network):
self.network = network
self.web_auth_tokens = {}
@@ -1011,17 +1053,15 @@ class SessionKeyGenerator:
token = self._get_web_auth_token()
- url = (
- f"{self.network.homepage}/api/auth/"
- f"?api_key={self.network.api_key}"
- f"&token={token}"
+ url = "{homepage}/api/auth/?api_key={api}&token={token}".format(
+ homepage=self.network.homepage, api=self.network.api_key, token=token
)
self.web_auth_tokens[url] = token
return url
- def get_web_auth_session_key_username(self, url, token: str = ""):
+ def get_web_auth_session_key_username(self, url, token=""):
"""
Retrieves the session key/username of a web authorization process by its URL.
"""
@@ -1041,7 +1081,7 @@ class SessionKeyGenerator:
username = doc.getElementsByTagName("name")[0].firstChild.data
return session_key, username
- def get_web_auth_session_key(self, url, token: str = ""):
+ def get_web_auth_session_key(self, url, token=""):
"""
Retrieves the session key of a web authorization process by its URL.
"""
@@ -1083,7 +1123,7 @@ Image = collections.namedtuple(
def _string_output(func):
def r(*args):
- return str(func(*args))
+ return _string(func(*args))
return r
@@ -1093,11 +1133,11 @@ class _BaseObject:
network = None
- def __init__(self, network, ws_prefix) -> None:
+ def __init__(self, network, ws_prefix):
self.network = network
self.ws_prefix = ws_prefix
- def _request(self, method_name, cacheable: bool = False, params=None):
+ def _request(self, method_name, cacheable=False, params=None):
if not params:
params = self._get_params()
@@ -1128,12 +1168,7 @@ class _BaseObject:
return first_child.wholeText.strip()
def _get_things(
- self,
- method,
- thing_type,
- params=None,
- cacheable: bool = True,
- stream: bool = False,
+ self, method, thing_type, params=None, cacheable=True, stream=False
):
"""Returns a list of the most played thing_types by this thing."""
@@ -1158,7 +1193,7 @@ class _BaseObject:
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.
"""
return self.get_wiki("published")
@@ -1172,7 +1207,7 @@ class _BaseObject:
def get_wiki_content(self):
"""
- Returns the content of the wiki.
+ Returns the summary of the wiki.
Only for Album/Track.
"""
return self.get_wiki("content")
@@ -1198,7 +1233,7 @@ class _BaseObject:
class _Chartable(_BaseObject):
"""Common functions for classes with charts."""
- def __init__(self, network, ws_prefix) -> None:
+ def __init__(self, network, ws_prefix):
super().__init__(network=network, ws_prefix=ws_prefix)
def get_weekly_chart_dates(self):
@@ -1242,10 +1277,8 @@ class _Chartable(_BaseObject):
from_date value to the to_date value.
chart_kind should be one of "album", "artist" or "track"
"""
- import sys
-
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()
if from_date and to_date:
@@ -1271,10 +1304,10 @@ class _Chartable(_BaseObject):
class _Taggable(_BaseObject):
"""Common functions for classes with tags."""
- def __init__(self, network, ws_prefix) -> None:
+ def __init__(self, network, ws_prefix):
super().__init__(network=network, ws_prefix=ws_prefix)
- def add_tags(self, tags) -> None:
+ def add_tags(self, tags):
"""Adds one or several tags.
* tags: A sequence of tag names or Tag objects.
"""
@@ -1282,7 +1315,7 @@ class _Taggable(_BaseObject):
for tag in tags:
self.add_tag(tag)
- def add_tag(self, tag) -> None:
+ def add_tag(self, tag):
"""Adds one tag.
* tag: a tag name or a Tag object.
"""
@@ -1295,7 +1328,7 @@ class _Taggable(_BaseObject):
self._request(self.ws_prefix + ".addTags", False, params)
- def remove_tag(self, tag) -> None:
+ def remove_tag(self, tag):
"""Remove a user's tag from this object."""
if isinstance(tag, Tag):
@@ -1320,7 +1353,7 @@ class _Taggable(_BaseObject):
return tags
- def remove_tags(self, tags) -> None:
+ def remove_tags(self, tags):
"""Removes one or several tags from this object.
* tags: a sequence of tag names or Tag objects.
"""
@@ -1328,12 +1361,12 @@ class _Taggable(_BaseObject):
for tag in tags:
self.remove_tag(tag)
- def clear_tags(self) -> None:
- """Clears all the user-set tags."""
+ def clear_tags(self):
+ """Clears all the user-set tags. """
self.remove_tags(*(self.get_tags()))
- def set_tags(self, tags) -> None:
+ def set_tags(self, tags):
"""Sets this object's tags to only those tags.
* tags: a sequence of tag names or Tag objects.
"""
@@ -1357,11 +1390,11 @@ class _Taggable(_BaseObject):
new_tags.append(tag)
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])
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])
self.remove_tags(to_remove)
@@ -1396,13 +1429,13 @@ class PyLastError(Exception):
class WSError(PyLastError):
"""Exception related to the Network web service"""
- def __init__(self, network, status, details) -> None:
+ def __init__(self, network, status, details):
self.status = status
self.details = details
self.network = network
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return self.details
def get_id(self):
@@ -1440,26 +1473,25 @@ class WSError(PyLastError):
class MalformedResponseError(PyLastError):
"""Exception conveying a malformed response from the music network."""
- def __init__(self, network, underlying_error) -> None:
+ def __init__(self, network, underlying_error):
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}"
+ def __str__(self):
+ return "Malformed response from {}. Underlying error: {}".format(
+ self.network.name, str(self.underlying_error)
)
class NetworkError(PyLastError):
"""Exception conveying a problem in sending a request to Last.fm"""
- def __init__(self, network, underlying_error) -> None:
+ def __init__(self, network, underlying_error):
self.network = network
self.underlying_error = underlying_error
- def __str__(self) -> str:
- return f"NetworkError: {self.underlying_error}"
+ def __str__(self):
+ return "NetworkError: %s" % str(self.underlying_error)
class _Opus(_Taggable):
@@ -1471,9 +1503,7 @@ class _Opus(_Taggable):
__hash__ = _BaseObject.__hash__
- def __init__(
- self, artist, title, network, ws_prefix, username=None, info=None
- ) -> None:
+ def __init__(self, artist, title, network, ws_prefix, username=None, info=None):
"""
Create an opus instance.
# Parameters:
@@ -1493,23 +1523,23 @@ class _Opus(_Taggable):
self.artist = Artist(artist, self.network)
self.title = title
- self.username = (
- username if username else network.username
- ) # Default to current user
+ self.username = username
self.info = info
- def __repr__(self) -> str:
- return (
- f"pylast.{self.ws_prefix.title()}"
- f"({repr(self.artist.name)}, {repr(self.title)}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.{}({}, {}, {})".format(
+ self.ws_prefix.title(),
+ repr(self.artist.name),
+ repr(self.title),
+ repr(self.network),
)
@_string_output
- def __str__(self) -> str:
- return f"{self.get_artist().get_name()} - {self.get_title()}"
+ def __str__(self):
+ return _unicode("%s - %s") % (self.get_artist().get_name(), self.get_title())
def __eq__(self, other):
- if type(self) is not type(other):
+ if type(self) != type(other):
return False
a = self.get_title().lower()
b = other.get_title().lower()
@@ -1546,8 +1576,8 @@ class _Opus(_Taggable):
)
return self.info["image"][size]
- def get_title(self, properly_capitalized: bool = False):
- """Returns the album or track title."""
+ def get_title(self, properly_capitalized=False):
+ """Returns the artist or track title."""
if properly_capitalized:
self.title = _extract(
self._request(self.ws_prefix + ".getInfo", True), "name"
@@ -1555,7 +1585,7 @@ class _Opus(_Taggable):
return self.title
- def get_name(self, properly_capitalized: bool = False):
+ def get_name(self, properly_capitalized=False):
"""Returns the album or track title (alias to get_title())."""
return self.get_title(properly_capitalized)
@@ -1590,7 +1620,7 @@ class _Opus(_Taggable):
)
)
- def get_mbid(self) -> str | None:
+ def get_mbid(self):
"""Returns the MusicBrainz ID of the album or track."""
doc = self._request(self.ws_prefix + ".getInfo", cacheable=True)
@@ -1599,7 +1629,7 @@ class _Opus(_Taggable):
lfm = doc.getElementsByTagName("lfm")[0]
opus = next(self._get_children_by_tag_name(lfm, self.ws_prefix))
mbid = next(self._get_children_by_tag_name(opus, "mbid"))
- return mbid.firstChild.nodeValue if mbid.firstChild else None
+ return mbid.firstChild.nodeValue
except StopIteration:
return None
@@ -1616,7 +1646,7 @@ class Album(_Opus):
__hash__ = _Opus.__hash__
- def __init__(self, artist, title, network, username=None, info=None) -> None:
+ def __init__(self, artist, title, network, username=None, info=None):
super().__init__(artist, title, network, "album", username, info)
def get_tracks(self):
@@ -1661,7 +1691,7 @@ class Artist(_Taggable):
__hash__ = _BaseObject.__hash__
- def __init__(self, name, network, username=None, info=None) -> None:
+ def __init__(self, name, network, username=None, info=None):
"""Create an artist object.
# Parameters:
* name str: The artist's name.
@@ -1676,14 +1706,14 @@ class Artist(_Taggable):
self.username = username
self.info = info
- def __repr__(self) -> str:
- return f"pylast.Artist({repr(self.get_name())}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.Artist({}, {})".format(repr(self.get_name()), repr(self.network))
def __unicode__(self):
return str(self.get_name())
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return self.__unicode__()
def __eq__(self, other):
@@ -1698,7 +1728,7 @@ class Artist(_Taggable):
def _get_params(self):
return {self.ws_prefix: self.get_name()}
- def get_name(self, properly_capitalized: bool = False):
+ def get_name(self, properly_capitalized=False):
"""Returns the name of the artist.
If properly_capitalized was asserted then the name would be downloaded
overwriting the given one."""
@@ -1752,6 +1782,15 @@ class Artist(_Taggable):
)
return self.listener_count
+ def is_streamable(self):
+ """Returns True if the artist is streamable."""
+
+ return bool(
+ _number(
+ _extract(self._request(self.ws_prefix + ".getInfo", True), "streamable")
+ )
+ )
+
def get_bio(self, section, language=None):
"""
Returns a section of the bio.
@@ -1764,14 +1803,9 @@ class Artist(_Taggable):
else:
params = None
- try:
- bio = self._extract_cdata_from_request(
- self.ws_prefix + ".getInfo", section, params
- )
- except IndexError:
- bio = None
-
- return bio
+ return self._extract_cdata_from_request(
+ self.ws_prefix + ".getInfo", section, params
+ )
def get_bio_published_date(self):
"""Returns the date on which the artist's biography was published."""
@@ -1805,7 +1839,7 @@ class Artist(_Taggable):
return artists
- def get_top_albums(self, limit=None, cacheable: bool = True, stream: bool = False):
+ def get_top_albums(self, limit=None, cacheable=True, stream=False):
"""Returns a list of the top albums."""
params = self._get_params()
if limit:
@@ -1813,7 +1847,7 @@ class Artist(_Taggable):
return self._get_things("getTopAlbums", Album, params, cacheable, stream=stream)
- def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False):
+ def get_top_tracks(self, limit=None, cacheable=True, stream=False):
"""Returns a list of the most played Tracks by this artist."""
params = self._get_params()
if limit:
@@ -1851,16 +1885,16 @@ class Country(_BaseObject):
__hash__ = _BaseObject.__hash__
- def __init__(self, name, network) -> None:
+ def __init__(self, name, network):
super().__init__(network=network, ws_prefix="geo")
self.name = name
- def __repr__(self) -> str:
- return f"pylast.Country({repr(self.name)}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.Country({}, {})".format(repr(self.name), repr(self.network))
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return self.get_name()
def __eq__(self, other):
@@ -1873,11 +1907,11 @@ class Country(_BaseObject):
return {"country": self.get_name()}
def get_name(self):
- """Returns the country name."""
+ """Returns the country name. """
return self.name
- def get_top_artists(self, limit=None, cacheable: bool = True):
+ def get_top_artists(self, limit=None, cacheable=True):
"""Returns a sequence of the most played artists."""
params = self._get_params()
if limit:
@@ -1887,7 +1921,7 @@ class Country(_BaseObject):
return _extract_top_artists(doc, self)
- def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False):
+ def get_top_tracks(self, limit=None, cacheable=True, stream=False):
"""Returns a sequence of the most played tracks"""
params = self._get_params()
if limit:
@@ -1926,7 +1960,7 @@ class Library(_BaseObject):
__hash__ = _BaseObject.__hash__
- def __init__(self, user, network) -> None:
+ def __init__(self, user, network):
super().__init__(network=network, ws_prefix="library")
if isinstance(user, User):
@@ -1934,11 +1968,11 @@ class Library(_BaseObject):
else:
self.user = User(user, self.network)
- def __repr__(self) -> str:
- return f"pylast.Library({repr(self.user)}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.Library({}, {})".format(repr(self.user), repr(self.network))
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return repr(self.get_user()) + "'s Library"
def _get_params(self):
@@ -1948,9 +1982,7 @@ class Library(_BaseObject):
"""Returns the user who owns this library."""
return self.user
- def get_artists(
- self, limit: int = 50, cacheable: bool = True, stream: bool = False
- ):
+ def get_artists(self, limit=50, cacheable=True, stream=False):
"""
Returns a sequence of Album objects
if limit==None it will return all (may take a while)
@@ -1977,16 +2009,16 @@ class Tag(_Chartable):
__hash__ = _BaseObject.__hash__
- def __init__(self, name, network) -> None:
+ def __init__(self, name, network):
super().__init__(network=network, ws_prefix="tag")
self.name = name
- def __repr__(self) -> str:
- return f"pylast.Tag({repr(self.name)}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.Tag({}, {})".format(repr(self.name), repr(self.network))
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return self.get_name()
def __eq__(self, other):
@@ -1998,8 +2030,8 @@ class Tag(_Chartable):
def _get_params(self):
return {self.ws_prefix: self.get_name()}
- def get_name(self, properly_capitalized: bool = False):
- """Returns the name of the tag."""
+ def get_name(self, properly_capitalized=False):
+ """Returns the name of the tag. """
if properly_capitalized:
self.name = _extract(
@@ -2008,7 +2040,7 @@ class Tag(_Chartable):
return self.name
- def get_top_albums(self, limit=None, cacheable: bool = True):
+ def get_top_albums(self, limit=None, cacheable=True):
"""Returns a list of the top albums."""
params = self._get_params()
if limit:
@@ -2018,7 +2050,7 @@ class Tag(_Chartable):
return _extract_top_albums(doc, self.network)
- def get_top_tracks(self, limit=None, cacheable: bool = True, stream: bool = False):
+ def get_top_tracks(self, limit=None, cacheable=True, stream=False):
"""Returns a list of the most played Tracks for this tag."""
params = self._get_params()
if limit:
@@ -2026,7 +2058,7 @@ class Tag(_Chartable):
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
- def get_top_artists(self, limit=None, cacheable: bool = True):
+ def get_top_artists(self, limit=None, cacheable=True):
"""Returns a sequence of the most played artists."""
params = self._get_params()
@@ -2064,7 +2096,7 @@ class Track(_Opus):
__hash__ = _Opus.__hash__
- def __init__(self, artist, title, network, username=None, info=None) -> None:
+ def __init__(self, artist, title, network, username=None, info=None):
super().__init__(artist, title, network, "track", username, info)
def get_correction(self):
@@ -2092,6 +2124,20 @@ class Track(_Opus):
loved = _number(_extract(doc, "userloved"))
return bool(loved)
+ def is_streamable(self):
+ """Returns True if the track is available at Last.fm."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+ return _extract(doc, "streamable") == "1"
+
+ def is_fulltrack_available(self):
+ """Returns True if the full track is available for streaming."""
+
+ doc = self._request(self.ws_prefix + ".getInfo", True)
+ return (
+ doc.getElementsByTagName("streamable")[0].getAttribute("fulltrack") == "1"
+ )
+
def get_album(self):
"""Returns the album object of this track."""
if "album" in self.info and self.info["album"] is not None:
@@ -2107,13 +2153,13 @@ class Track(_Opus):
node = doc.getElementsByTagName("album")[0]
return Album(_extract(node, "artist"), _extract(node, "title"), self.network)
- def love(self) -> None:
- """Adds the track to the user's loved tracks."""
+ def love(self):
+ """Adds the track to the user's loved tracks. """
self._request(self.ws_prefix + ".love")
- def unlove(self) -> None:
- """Remove the track to the user's loved tracks."""
+ def unlove(self):
+ """Remove the track to the user's loved tracks. """
self._request(self.ws_prefix + ".unlove")
@@ -2173,16 +2219,16 @@ class User(_Chartable):
__hash__ = _BaseObject.__hash__
- def __init__(self, user_name, network) -> None:
+ def __init__(self, user_name, network):
super().__init__(network=network, ws_prefix="user")
self.name = user_name
- def __repr__(self) -> str:
- return f"pylast.User({repr(self.name)}, {repr(self.network)})"
+ def __repr__(self):
+ return "pylast.User({}, {})".format(repr(self.name), repr(self.network))
@_string_output
- def __str__(self) -> str:
+ def __str__(self):
return self.get_name()
def __eq__(self, other):
@@ -2207,7 +2253,7 @@ class User(_Chartable):
Track(track_artist, title, self.network), album, date, timestamp
)
- def get_name(self, properly_capitalized: bool = False):
+ def get_name(self, properly_capitalized=False):
"""Returns the user name."""
if properly_capitalized:
@@ -2217,10 +2263,8 @@ class User(_Chartable):
return self.name
- def get_friends(
- self, limit: int = 50, cacheable: bool = False, stream: bool = False
- ):
- """Returns a list of the user's friends."""
+ def get_friends(self, limit=50, cacheable=False, stream=False):
+ """Returns a list of the user's friends. """
def _get_friends():
for node in _collect_nodes(
@@ -2230,9 +2274,7 @@ class User(_Chartable):
return _get_friends() if stream else list(_get_friends())
- def get_loved_tracks(
- self, limit: int = 50, cacheable: bool = True, stream: bool = False
- ):
+ def get_loved_tracks(self, limit=50, cacheable=True, stream=False):
"""
Returns this user's loved track as a sequence of LovedTrack objects in
reverse order of their timestamp, all the way back to the first track.
@@ -2297,12 +2339,12 @@ class User(_Chartable):
def get_recent_tracks(
self,
- limit: int = 10,
- cacheable: bool = True,
- time_from: int | None = None,
- time_to: int | None = None,
- stream: bool = False,
- now_playing: bool = False,
+ limit=10,
+ cacheable=True,
+ time_from=None,
+ time_to=None,
+ stream=False,
+ now_playing=False,
):
"""
Returns this user's played track as a sequence of PlayedTrack objects
@@ -2311,11 +2353,13 @@ class User(_Chartable):
Parameters:
limit : If None, it will try to pull all the available data.
from (Optional) : Beginning timestamp of a range - only display
- scrobbles after this time, in Unix timestamp format (integer
- number of seconds since 00:00:00, January 1st 1970 UTC).
+ scrobbles after this time, in UNIX timestamp format (integer
+ 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
- before this time, in Unix timestamp format (integer number of
- seconds since 00:00:00, January 1st 1970 UTC).
+ before this time, in UNIX timestamp format (integer number of
+ 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.
This method uses caching. Enable caching only if you're pulling a
@@ -2384,13 +2428,13 @@ class User(_Chartable):
return _extract(doc, "registered")
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)
return int(doc.getElementsByTagName("registered")[0].getAttribute("unixtime"))
- def get_tagged_albums(self, tag, limit=None, cacheable: bool = True):
+ def get_tagged_albums(self, tag, limit=None, cacheable=True):
"""Returns the albums tagged by a user."""
params = self._get_params()
@@ -2412,7 +2456,7 @@ class User(_Chartable):
doc = self._request(self.ws_prefix + ".getpersonaltags", True, params)
return _extract_artists(doc, self.network)
- def get_tagged_tracks(self, tag, limit=None, cacheable: bool = True):
+ def get_tagged_tracks(self, tag, limit=None, cacheable=True):
"""Returns the tracks tagged by a user."""
params = self._get_params()
@@ -2423,7 +2467,7 @@ class User(_Chartable):
doc = self._request(self.ws_prefix + ".getpersonaltags", cacheable, params)
return _extract_tracks(doc, self.network)
- def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable: bool = True):
+ def get_top_albums(self, period=PERIOD_OVERALL, limit=None, cacheable=True):
"""Returns the top albums played by a user.
* period: The period of time. Possible values:
o PERIOD_OVERALL
@@ -2463,7 +2507,7 @@ class User(_Chartable):
return _extract_top_artists(doc, self.network)
- def get_top_tags(self, limit=None, cacheable: bool = True):
+ def get_top_tags(self, limit=None, cacheable=True):
"""
Returns a sequence of the top tags used by this user with their counts
as TopItem objects.
@@ -2488,11 +2532,7 @@ class User(_Chartable):
return seq
def get_top_tracks(
- self,
- period=PERIOD_OVERALL,
- limit=None,
- cacheable: bool = True,
- stream: bool = False,
+ self, period=PERIOD_OVERALL, limit=None, cacheable=True, stream=False
):
"""Returns the top tracks played by a user.
* period: The period of time. Possible values:
@@ -2506,13 +2546,12 @@ class User(_Chartable):
params = self._get_params()
params["period"] = period
- params["limit"] = limit
+ if limit:
+ params["limit"] = limit
return self._get_things("getTopTracks", Track, params, cacheable, stream=stream)
- def get_track_scrobbles(
- self, artist, track, cacheable: bool = False, stream: bool = False
- ):
+ def get_track_scrobbles(self, artist, track, cacheable=False, stream=False):
"""
Get a list of this user's scrobbles of this artist's track,
including scrobble time.
@@ -2570,19 +2609,19 @@ class User(_Chartable):
return self.network._get_url(domain_name, "user") % {"name": name}
def get_library(self):
- """Returns the associated Library object."""
+ """Returns the associated Library object. """
return Library(self, self.network)
class AuthenticatedUser(User):
- def __init__(self, network) -> None:
+ def __init__(self, network):
super().__init__(user_name=network.username, network=network)
def _get_params(self):
return {"user": self.get_name()}
- def get_name(self, properly_capitalized: bool = False):
+ def get_name(self, properly_capitalized=False):
"""Returns the name of the authenticated user."""
return super().get_name(properly_capitalized=properly_capitalized)
@@ -2590,7 +2629,7 @@ class AuthenticatedUser(User):
class _Search(_BaseObject):
"""An abstract class. Use one of its derivatives."""
- def __init__(self, ws_prefix, search_terms, network) -> None:
+ def __init__(self, ws_prefix, search_terms, network):
super().__init__(network, ws_prefix)
self._ws_prefix = ws_prefix
@@ -2630,7 +2669,7 @@ class _Search(_BaseObject):
class AlbumSearch(_Search):
"""Search for an album by name."""
- def __init__(self, album_name, network) -> None:
+ def __init__(self, album_name, network):
super().__init__(
ws_prefix="album", search_terms={"album": album_name}, network=network
)
@@ -2657,7 +2696,7 @@ class AlbumSearch(_Search):
class ArtistSearch(_Search):
"""Search for an artist by artist name."""
- def __init__(self, artist_name, network) -> None:
+ def __init__(self, artist_name, network):
super().__init__(
ws_prefix="artist", search_terms={"artist": artist_name}, network=network
)
@@ -2686,7 +2725,7 @@ class TrackSearch(_Search):
down by specifying the artist name, set it to empty string.
"""
- def __init__(self, artist_name, track_title, network) -> None:
+ def __init__(self, artist_name, track_title, network):
super().__init__(
ws_prefix="track",
search_terms={"track": track_title, "artist": artist_name},
@@ -2724,10 +2763,18 @@ def md5(text):
def _unicode(text):
if isinstance(text, bytes):
return str(text, "utf-8")
+ elif isinstance(text, str):
+ return text
else:
return str(text)
+def _string(string):
+ if isinstance(string, str):
+ return string
+ return str(string)
+
+
def cleanup_nodes(doc):
"""
Remove text nodes containing only whitespace
@@ -2738,9 +2785,7 @@ def cleanup_nodes(doc):
return doc
-def _collect_nodes(
- limit, sender, method_name, cacheable, params=None, stream: bool = False
-):
+def _collect_nodes(limit, sender, method_name, cacheable, params=None, stream=False):
"""
Returns a sequence of dom.Node objects about as close to limit as possible
"""
@@ -2779,8 +2824,7 @@ def _collect_nodes(
main.getAttribute("totalPages") or main.getAttribute("totalpages")
)
else:
- msg = "No total pages attribute"
- raise PyLastError(msg)
+ raise PyLastError("No total pages attribute")
for node in main.childNodes:
if not node.nodeType == xml.dom.Node.TEXT_NODE and (
@@ -2796,7 +2840,7 @@ def _collect_nodes(
return _stream_collect_nodes() if stream else list(_stream_collect_nodes())
-def _extract(node, name, index: int = 0):
+def _extract(node, name, index=0):
"""Extracts a value from the xml string"""
nodes = node.getElementsByTagName(name)
@@ -2876,7 +2920,7 @@ def _extract_tracks(doc, network):
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()
+ return quote_plus(quote_plus(_string(text))).lower()
def _number(string):
@@ -2897,25 +2941,9 @@ def _number(string):
def _unescape_htmlentity(string):
mapping = html.entities.name2codepoint
for key in mapping:
- string = string.replace(f"&{key};", chr(mapping[key]))
+ string = string.replace("&%s;" % 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
diff --git a/tests/test_album.py b/tests/test_album.py
index 1146f12..d6bf3e1 100755
--- a/tests/test_album.py
+++ b/tests/test_album.py
@@ -2,15 +2,13 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import pylast
from .test_pylast import TestPyLastWithLastFm
class TestPyLastAlbum(TestPyLastWithLastFm):
- def test_album_tags_are_topitems(self) -> None:
+ def test_album_tags_are_topitems(self):
# Arrange
album = self.network.get_album("Test Artist", "Test Album")
@@ -21,14 +19,14 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
assert len(tags) > 0
assert isinstance(tags[0], pylast.TopItem)
- def test_album_is_hashable(self) -> None:
+ def test_album_is_hashable(self):
# Arrange
album = self.network.get_album("Test Artist", "Test Album")
# Act/Assert
self.helper_is_thing_hashable(album)
- def test_album_in_recent_tracks(self) -> None:
+ def test_album_in_recent_tracks(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
@@ -39,7 +37,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
# Assert
assert hasattr(track, "album")
- def test_album_wiki_content(self) -> None:
+ def test_album_wiki_content(self):
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
@@ -50,7 +48,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
assert wiki is not None
assert len(wiki) >= 1
- def test_album_wiki_published_date(self) -> None:
+ def test_album_wiki_published_date(self):
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
@@ -61,7 +59,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
assert wiki is not None
assert len(wiki) >= 1
- def test_album_wiki_summary(self) -> None:
+ def test_album_wiki_summary(self):
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
@@ -72,7 +70,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
assert wiki is not None
assert len(wiki) >= 1
- def test_album_eq_none_is_false(self) -> None:
+ def test_album_eq_none_is_false(self):
# Arrange
album1 = None
album2 = pylast.Album("Test Artist", "Test Album", self.network)
@@ -80,7 +78,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
# Act / Assert
assert album1 != album2
- def test_album_ne_none_is_true(self) -> None:
+ def test_album_ne_none_is_true(self):
# Arrange
album1 = None
album2 = pylast.Album("Test Artist", "Test Album", self.network)
@@ -88,7 +86,7 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
# Act / Assert
assert album1 != album2
- def test_get_cover_image(self) -> None:
+ def test_get_cover_image(self):
# Arrange
album = self.network.get_album("Test Artist", "Test Album")
@@ -96,25 +94,5 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
image = album.get_cover_image()
# Assert
- assert image.startswith("https://")
- assert image.endswith(".gif") or image.endswith(".png")
-
- def test_mbid(self) -> None:
- # Arrange
- album = self.network.get_album("Radiohead", "OK Computer")
-
- # Act
- mbid = album.get_mbid()
-
- # Assert
- assert mbid == "0b6b4ba0-d36f-47bd-b4ea-6a5b91842d29"
-
- def test_no_mbid(self) -> None:
- # Arrange
- album = self.network.get_album("Test Artist", "Test Album")
-
- # Act
- mbid = album.get_mbid()
-
- # Assert
- assert mbid is None
+ self.assert_startswith(image, "https://")
+ self.assert_endswith(image, ".png")
diff --git a/tests/test_artist.py b/tests/test_artist.py
index d4f9134..4e8d694 100755
--- a/tests/test_artist.py
+++ b/tests/test_artist.py
@@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import pytest
import pylast
@@ -12,7 +10,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
class TestPyLastArtist(TestPyLastWithLastFm):
- def test_repr(self) -> None:
+ def test_repr(self):
# Arrange
artist = pylast.Artist("Test Artist", self.network)
@@ -22,16 +20,16 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
assert representation.startswith("pylast.Artist('Test Artist',")
- def test_artist_is_hashable(self) -> None:
+ def test_artist_is_hashable(self):
# Arrange
- test_artist = self.network.get_artist("Radiohead")
+ test_artist = self.network.get_artist("Test Artist")
artist = test_artist.get_similar(limit=2)[0].item
assert isinstance(artist, pylast.Artist)
# Act/Assert
self.helper_is_thing_hashable(artist)
- def test_bio_published_date(self) -> None:
+ def test_bio_published_date(self):
# Arrange
artist = pylast.Artist("Test Artist", self.network)
@@ -42,7 +40,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert bio is not None
assert len(bio) >= 1
- def test_bio_content(self) -> None:
+ def test_bio_content(self):
# Arrange
artist = pylast.Artist("Test Artist", self.network)
@@ -53,7 +51,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert bio is not None
assert len(bio) >= 1
- def test_bio_content_none(self) -> None:
+ def test_bio_content_none(self):
# Arrange
# An artist with no biography, with "" in the API XML
artist = pylast.Artist("Mr Sizef + Unquote", self.network)
@@ -64,7 +62,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
assert bio is None
- def test_bio_summary(self) -> None:
+ def test_bio_summary(self):
# Arrange
artist = pylast.Artist("Test Artist", self.network)
@@ -75,7 +73,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert bio is not None
assert len(bio) >= 1
- def test_artist_top_tracks(self) -> None:
+ def test_artist_top_tracks(self):
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
@@ -86,7 +84,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
- def test_artist_top_albums(self) -> None:
+ def test_artist_top_albums(self):
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
@@ -109,7 +107,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
assert len(things) == test_limit
- def test_artist_top_albums_limit_default(self) -> None:
+ def test_artist_top_albums_limit_default(self):
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
@@ -120,7 +118,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
assert len(things) == 50
- def test_artist_listener_count(self) -> None:
+ def test_artist_listener_count(self):
# Arrange
artist = self.network.get_artist("Test Artist")
@@ -132,7 +130,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert count > 0
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_tag_artist(self) -> None:
+ def test_tag_artist(self):
# Arrange
artist = self.network.get_artist("Test Artist")
# artist.clear_tags()
@@ -147,7 +145,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_remove_tag_of_type_text(self) -> None:
+ def test_remove_tag_of_type_text(self):
# Arrange
tag = "testing" # text
artist = self.network.get_artist("Test Artist")
@@ -162,7 +160,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert not found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_remove_tag_of_type_tag(self) -> None:
+ def test_remove_tag_of_type_tag(self):
# Arrange
tag = pylast.Tag("testing", self.network) # Tag
artist = self.network.get_artist("Test Artist")
@@ -177,7 +175,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert not found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_remove_tags(self) -> None:
+ def test_remove_tags(self):
# Arrange
tags = ["removetag1", "removetag2"]
artist = self.network.get_artist("Test Artist")
@@ -197,7 +195,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert not found2
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_set_tags(self) -> None:
+ def test_set_tags(self):
# Arrange
tags = ["sometag1", "sometag2"]
artist = self.network.get_artist("Test Artist 2")
@@ -221,7 +219,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert found1
assert found2
- def test_artists(self) -> None:
+ def test_artists(self):
# Arrange
artist1 = self.network.get_artist("Radiohead")
artist2 = self.network.get_artist("Portishead")
@@ -231,6 +229,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
mbid = artist1.get_mbid()
playcount = artist1.get_playcount()
+ streamable = artist1.is_streamable()
name = artist1.get_name(properly_capitalized=False)
name_cap = artist1.get_name(properly_capitalized=True)
@@ -240,8 +239,9 @@ class TestPyLastArtist(TestPyLastWithLastFm):
assert name.lower() == name_cap.lower()
assert url == "https://www.last.fm/music/radiohead"
assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711"
+ assert isinstance(streamable, bool)
- def test_artist_eq_none_is_false(self) -> None:
+ def test_artist_eq_none_is_false(self):
# Arrange
artist1 = None
artist2 = pylast.Artist("Test Artist", self.network)
@@ -249,7 +249,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Act / Assert
assert artist1 != artist2
- def test_artist_ne_none_is_true(self) -> None:
+ def test_artist_ne_none_is_true(self):
# Arrange
artist1 = None
artist2 = pylast.Artist("Test Artist", self.network)
@@ -257,7 +257,7 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Act / Assert
assert artist1 != artist2
- def test_artist_get_correction(self) -> None:
+ def test_artist_get_correction(self):
# Arrange
artist = pylast.Artist("guns and roses", self.network)
@@ -267,7 +267,8 @@ class TestPyLastArtist(TestPyLastWithLastFm):
# Assert
assert corrected_artist_name == "Guns N' Roses"
- def test_get_userplaycount(self) -> None:
+ @pytest.mark.xfail
+ def test_get_userplaycount(self):
# Arrange
artist = pylast.Artist("John Lennon", self.network, username=self.username)
@@ -275,4 +276,4 @@ class TestPyLastArtist(TestPyLastWithLastFm):
playcount = artist.get_userplaycount()
# Assert
- assert playcount >= 0
+ assert playcount >= 0 # whilst xfail: # pragma: no cover
diff --git a/tests/test_country.py b/tests/test_country.py
index 1636b96..4561d82 100755
--- a/tests/test_country.py
+++ b/tests/test_country.py
@@ -2,22 +2,20 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import pylast
from .test_pylast import TestPyLastWithLastFm
class TestPyLastCountry(TestPyLastWithLastFm):
- def test_country_is_hashable(self) -> None:
+ def test_country_is_hashable(self):
# Arrange
country = self.network.get_country("Italy")
# Act/Assert
self.helper_is_thing_hashable(country)
- def test_countries(self) -> None:
+ def test_countries(self):
# Arrange
country1 = pylast.Country("Italy", self.network)
country2 = pylast.Country("Finland", self.network)
diff --git a/tests/test_library.py b/tests/test_library.py
index 592436d..dea876d 100755
--- a/tests/test_library.py
+++ b/tests/test_library.py
@@ -2,15 +2,13 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import pylast
from .test_pylast import TestPyLastWithLastFm
class TestPyLastLibrary(TestPyLastWithLastFm):
- def test_repr(self) -> None:
+ def test_repr(self):
# Arrange
library = pylast.Library(user=self.username, network=self.network)
@@ -18,9 +16,9 @@ class TestPyLastLibrary(TestPyLastWithLastFm):
representation = repr(library)
# Assert
- assert representation.startswith("pylast.Library(")
+ self.assert_startswith(representation, "pylast.Library(")
- def test_str(self) -> None:
+ def test_str(self):
# Arrange
library = pylast.Library(user=self.username, network=self.network)
@@ -28,23 +26,23 @@ class TestPyLastLibrary(TestPyLastWithLastFm):
string = str(library)
# 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):
# Arrange
library = pylast.Library(user=self.username, network=self.network)
# Act/Assert
self.helper_is_thing_hashable(library)
- def test_cacheable_library(self) -> None:
+ def test_cacheable_library(self):
# Arrange
library = pylast.Library(self.username, self.network)
# Act/Assert
self.helper_validate_cacheable(library, "get_artists")
- def test_get_user(self) -> None:
+ def test_get_user(self):
# Arrange
library = pylast.Library(user=self.username, network=self.network)
user_to_get = self.network.get_user(self.username)
diff --git a/tests/test_librefm.py b/tests/test_librefm.py
index 0d9e839..6b0f3dd 100755
--- a/tests/test_librefm.py
+++ b/tests/test_librefm.py
@@ -2,20 +2,18 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
from flaky import flaky
import pylast
-from .test_pylast import load_secrets
+from .test_pylast import PyLastTestCase, load_secrets
@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"""
- def test_libre_fm(self) -> None:
+ def test_libre_fm(self):
# Arrange
secrets = load_secrets()
username = secrets["username"]
@@ -29,7 +27,7 @@ class TestPyLastWithLibreFm:
# Assert
assert name == "Radiohead"
- def test_repr(self) -> None:
+ def test_repr(self):
# Arrange
secrets = load_secrets()
username = secrets["username"]
@@ -40,4 +38,4 @@ class TestPyLastWithLibreFm:
representation = repr(network)
# Assert
- assert representation.startswith("pylast.LibreFMNetwork(")
+ self.assert_startswith(representation, "pylast.LibreFMNetwork(")
diff --git a/tests/test_network.py b/tests/test_network.py
index 05672d6..b45fafa 100755
--- a/tests/test_network.py
+++ b/tests/test_network.py
@@ -1,9 +1,7 @@
+#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
-
-from __future__ import annotations
-
import re
import time
@@ -16,7 +14,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
class TestPyLastNetwork(TestPyLastWithLastFm):
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_scrobble(self) -> None:
+ def test_scrobble(self):
# Arrange
artist = "test artist"
title = "test title"
@@ -34,7 +32,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert str(last_scrobble.track.title).lower() == title
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_update_now_playing(self) -> None:
+ def test_update_now_playing(self):
# Arrange
artist = "Test Artist"
title = "test title"
@@ -58,7 +56,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert len(current_track.info["image"])
assert re.search(r"^http.+$", current_track.info["image"][pylast.SIZE_LARGE])
- def test_enable_rate_limiting(self) -> None:
+ def test_enable_rate_limiting(self):
# Arrange
assert not self.network.is_rate_limited()
@@ -75,7 +73,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert self.network.is_rate_limited()
assert now - then >= 0.2
- def test_disable_rate_limiting(self) -> None:
+ def test_disable_rate_limiting(self):
# Arrange
self.network.enable_rate_limit()
assert self.network.is_rate_limited()
@@ -90,14 +88,14 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert not self.network.is_rate_limited()
- def test_lastfm_network_name(self) -> None:
+ def test_lastfm_network_name(self):
# Act
name = str(self.network)
# Assert
assert name == "Last.fm Network"
- def test_geo_get_top_artists(self) -> None:
+ def test_geo_get_top_artists(self):
# Arrange
# Act
artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1)
@@ -107,7 +105,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert isinstance(artists[0], pylast.TopItem)
assert isinstance(artists[0].item, pylast.Artist)
- def test_geo_get_top_tracks(self) -> None:
+ def test_geo_get_top_tracks(self):
# Arrange
# Act
tracks = self.network.get_geo_top_tracks(
@@ -119,7 +117,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert isinstance(tracks[0], pylast.TopItem)
assert isinstance(tracks[0].item, pylast.Track)
- def test_network_get_top_artists_with_limit(self) -> None:
+ def test_network_get_top_artists_with_limit(self):
# Arrange
# Act
artists = self.network.get_top_artists(limit=1)
@@ -127,7 +125,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
- def test_network_get_top_tags_with_limit(self) -> None:
+ def test_network_get_top_tags_with_limit(self):
# Arrange
# Act
tags = self.network.get_top_tags(limit=1)
@@ -135,7 +133,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
- def test_network_get_top_tags_with_no_limit(self) -> None:
+ def test_network_get_top_tags_with_no_limit(self):
# Arrange
# Act
tags = self.network.get_top_tags()
@@ -143,7 +141,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag)
- def test_network_get_top_tracks_with_limit(self) -> None:
+ def test_network_get_top_tracks_with_limit(self):
# Arrange
# Act
tracks = self.network.get_top_tracks(limit=1)
@@ -151,7 +149,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(tracks, pylast.Track)
- def test_country_top_tracks(self) -> None:
+ def test_country_top_tracks(self):
# Arrange
country = self.network.get_country("Croatia")
@@ -161,7 +159,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
- def test_country_network_top_tracks(self) -> None:
+ def test_country_network_top_tracks(self):
# Arrange
# Act
things = self.network.get_geo_top_tracks("Croatia", limit=2)
@@ -169,7 +167,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
- def test_tag_top_tracks(self) -> None:
+ def test_tag_top_tracks(self):
# Arrange
tag = self.network.get_tag("blues")
@@ -179,7 +177,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
- def test_album_data(self) -> None:
+ def test_album_data(self):
# Arrange
thing = self.network.get_album("Test Artist", "Test Album")
@@ -199,7 +197,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert playcount > 1
assert "https://www.last.fm/music/test%2bartist/test%2balbum" == url
- def test_track_data(self) -> None:
+ def test_track_data(self):
# Arrange
thing = self.network.get_track("Test Artist", "test title")
@@ -220,7 +218,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert playcount > 1
assert "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle" == url
- def test_country_top_artists(self) -> None:
+ def test_country_top_artists(self):
# Arrange
country = self.network.get_country("Ukraine")
@@ -230,7 +228,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
- def test_caching(self) -> None:
+ def test_caching(self):
# Arrange
user = self.network.get_user("RJ")
@@ -245,9 +243,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
self.network.disable_caching()
assert not self.network.is_caching_enabled()
- def test_album_mbid(self) -> None:
+ def test_album_mbid(self):
# Arrange
- mbid = "03c91c40-49a6-44a7-90e7-a700edf97a62"
+ mbid = "a6a265bf-9f81-4055-8224-f7ac0aa6b937"
# Act
album = self.network.get_album_by_mbid(mbid)
@@ -255,10 +253,10 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert isinstance(album, pylast.Album)
- assert album.title == "Believe"
+ assert album.title.lower() == "test"
assert album_mbid == mbid
- def test_artist_mbid(self) -> None:
+ def test_artist_mbid(self):
# Arrange
mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55"
@@ -267,9 +265,9 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert isinstance(artist, pylast.Artist)
- assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist")
+ assert artist.name == "MusicBrainz Test Artist"
- def test_track_mbid(self) -> None:
+ def test_track_mbid(self):
# Arrange
mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0"
@@ -282,7 +280,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert track.title == "first"
assert track_mbid == mbid
- def test_init_with_token(self) -> None:
+ def test_init_with_token(self):
# Arrange/Act
msg = None
try:
@@ -297,19 +295,20 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert msg == "Unauthorized Token - This token has not been issued"
- def test_proxy(self) -> None:
+ def test_proxy(self):
# Arrange
- proxy = "http://example.com:1234"
+ host = "https://example.com"
+ port = 1234
# Act / Assert
- self.network.enable_proxy(proxy)
+ self.network.enable_proxy(host, port)
assert self.network.is_proxy_enabled()
- assert self.network.proxy == "http://example.com:1234"
+ assert self.network._get_proxy() == ["https://example.com", 1234]
self.network.disable_proxy()
assert not self.network.is_proxy_enabled()
- def test_album_search(self) -> None:
+ def test_album_search(self):
# Arrange
album = "Nevermind"
@@ -321,7 +320,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert isinstance(results, list)
assert isinstance(results[0], pylast.Album)
- def test_album_search_images(self) -> None:
+ def test_album_search_images(self):
# Arrange
album = "Nevermind"
search = self.network.search_for_album(album)
@@ -333,15 +332,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert len(images) == 4
- assert images[pylast.SIZE_SMALL].startswith("https://")
- assert images[pylast.SIZE_SMALL].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
+ self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
- assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
- assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
+ self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
- def test_artist_search(self) -> None:
+ def test_artist_search(self):
# Arrange
artist = "Nirvana"
@@ -353,7 +352,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert isinstance(results, list)
assert isinstance(results[0], pylast.Artist)
- def test_artist_search_images(self) -> None:
+ def test_artist_search_images(self):
# Arrange
artist = "Nirvana"
search = self.network.search_for_artist(artist)
@@ -365,15 +364,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert len(images) == 5
- assert images[pylast.SIZE_SMALL].startswith("https://")
- assert images[pylast.SIZE_SMALL].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
+ self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
- assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
- assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
+ self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
- def test_track_search(self) -> None:
+ def test_track_search(self):
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
@@ -386,7 +385,7 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
assert isinstance(results, list)
assert isinstance(results[0], pylast.Track)
- def test_track_search_images(self) -> None:
+ def test_track_search_images(self):
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
@@ -399,15 +398,15 @@ class TestPyLastNetwork(TestPyLastWithLastFm):
# Assert
assert len(images) == 4
- assert images[pylast.SIZE_SMALL].startswith("https://")
- assert images[pylast.SIZE_SMALL].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_SMALL], "https://")
+ self.assert_endswith(images[pylast.SIZE_SMALL], ".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
- assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
- assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
+ self.assert_startswith(images[pylast.SIZE_EXTRA_LARGE], "https://")
+ self.assert_endswith(images[pylast.SIZE_EXTRA_LARGE], ".png")
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):
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
diff --git a/tests/test_pylast.py b/tests/test_pylast.py
index c06a9c3..26f799c 100755
--- a/tests/test_pylast.py
+++ b/tests/test_pylast.py
@@ -2,9 +2,8 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import os
+import sys
import time
import pytest
@@ -12,7 +11,7 @@ from flaky import flaky
import pylast
-WRITE_TEST = False
+WRITE_TEST = sys.version_info[:2] == (3, 9)
def load_secrets(): # pragma: no cover
@@ -34,21 +33,29 @@ def load_secrets(): # pragma: no cover
return doc
-def _no_xfail_rerun_filter(err, name, test, plugin) -> bool:
+class PyLastTestCase:
+ def assert_startswith(self, s, prefix, start=None, end=None):
+ assert s.startswith(prefix, start, end)
+
+ def assert_endswith(self, s, suffix, start=None, end=None):
+ assert s.endswith(suffix, start, end)
+
+
+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:
+class TestPyLastWithLastFm(PyLastTestCase):
+
secrets = None
- @staticmethod
- def unix_timestamp() -> int:
+ def unix_timestamp(self):
return int(time.time())
@classmethod
- def setup_class(cls) -> None:
+ def setup_class(cls):
if cls.secrets is None:
cls.secrets = load_secrets()
@@ -65,8 +72,7 @@ class TestPyLastWithLastFm:
password_hash=password_hash,
)
- @staticmethod
- def helper_is_thing_hashable(thing) -> None:
+ def helper_is_thing_hashable(self, thing):
# Arrange
things = set()
@@ -77,8 +83,7 @@ class TestPyLastWithLastFm:
assert thing is not None
assert len(things) == 1
- @staticmethod
- def helper_validate_results(a, b, c) -> None:
+ def helper_validate_results(self, a, b, c):
# Assert
assert a is not None
assert b is not None
@@ -89,7 +94,7 @@ class TestPyLastWithLastFm:
assert a == b
assert b == c
- def helper_validate_cacheable(self, thing, function_name) -> None:
+ def helper_validate_cacheable(self, thing, function_name):
# Arrange
# get thing.function_name()
func = getattr(thing, function_name, None)
@@ -102,31 +107,27 @@ class TestPyLastWithLastFm:
# Assert
self.helper_validate_results(result1, result2, result3)
- @staticmethod
- def helper_at_least_one_thing_in_top_list(things, expected_type) -> None:
+ def helper_at_least_one_thing_in_top_list(self, things, expected_type):
# Assert
assert len(things) > 1
assert isinstance(things, list)
assert isinstance(things[0], pylast.TopItem)
assert isinstance(things[0].item, expected_type)
- @staticmethod
- def helper_only_one_thing_in_top_list(things, expected_type) -> None:
+ def helper_only_one_thing_in_top_list(self, things, expected_type):
# Assert
assert len(things) == 1
assert isinstance(things, list)
assert isinstance(things[0], pylast.TopItem)
assert isinstance(things[0].item, expected_type)
- @staticmethod
- def helper_only_one_thing_in_list(things, expected_type) -> None:
+ def helper_only_one_thing_in_list(self, things, expected_type):
# Assert
assert len(things) == 1
assert isinstance(things, list)
assert isinstance(things[0], expected_type)
- @staticmethod
- def helper_two_different_things_in_top_list(things, expected_type) -> None:
+ def helper_two_different_things_in_top_list(self, things, expected_type):
# Assert
assert len(things) == 2
thing1 = things[0]
diff --git a/tests/test_tag.py b/tests/test_tag.py
index 7a9675c..65544e0 100755
--- a/tests/test_tag.py
+++ b/tests/test_tag.py
@@ -2,22 +2,20 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import pylast
from .test_pylast import TestPyLastWithLastFm
class TestPyLastTag(TestPyLastWithLastFm):
- def test_tag_is_hashable(self) -> None:
+ def test_tag_is_hashable(self):
# Arrange
tag = self.network.get_top_tags(limit=1)[0]
# Act/Assert
self.helper_is_thing_hashable(tag)
- def test_tag_top_artists(self) -> None:
+ def test_tag_top_artists(self):
# Arrange
tag = self.network.get_tag("blues")
@@ -27,7 +25,7 @@ class TestPyLastTag(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
- def test_tag_top_albums(self) -> None:
+ def test_tag_top_albums(self):
# Arrange
tag = self.network.get_tag("blues")
@@ -37,7 +35,7 @@ class TestPyLastTag(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(albums, pylast.Album)
- def test_tags(self) -> None:
+ def test_tags(self):
# Arrange
tag1 = self.network.get_tag("blues")
tag2 = self.network.get_tag("rock")
diff --git a/tests/test_track.py b/tests/test_track.py
index db04d15..b56c018 100755
--- a/tests/test_track.py
+++ b/tests/test_track.py
@@ -1,9 +1,7 @@
+#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
-
-from __future__ import annotations
-
import time
import pytest
@@ -15,7 +13,7 @@ from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
class TestPyLastTrack(TestPyLastWithLastFm):
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_love(self) -> None:
+ def test_love(self):
# Arrange
artist = "Test Artist"
title = "test title"
@@ -31,7 +29,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert str(loved[0].track.title).lower() == "test title"
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
- def test_unlove(self) -> None:
+ def test_unlove(self):
# Arrange
artist = pylast.Artist("Test Artist", self.network)
title = "test title"
@@ -49,7 +47,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert str(loved[0].track.artist) != "Test Artist"
assert str(loved[0].track.title) != "test title"
- def test_user_play_count_in_track_info(self) -> None:
+ def test_user_play_count_in_track_info(self):
# Arrange
artist = "Test Artist"
title = "test title"
@@ -63,7 +61,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert count >= 0
- def test_user_loved_in_track_info(self) -> None:
+ def test_user_loved_in_track_info(self):
# Arrange
artist = "Test Artist"
title = "test title"
@@ -79,7 +77,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert isinstance(loved, bool)
assert not isinstance(loved, str)
- def test_track_is_hashable(self) -> None:
+ def test_track_is_hashable(self):
# Arrange
artist = self.network.get_artist("Test Artist")
track = artist.get_top_tracks(stream=False)[0].item
@@ -88,7 +86,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Act/Assert
self.helper_is_thing_hashable(track)
- def test_track_wiki_content(self) -> None:
+ def test_track_wiki_content(self):
# Arrange
track = pylast.Track("Test Artist", "test title", self.network)
@@ -99,7 +97,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert wiki is not None
assert len(wiki) >= 1
- def test_track_wiki_summary(self) -> None:
+ def test_track_wiki_summary(self):
# Arrange
track = pylast.Track("Test Artist", "test title", self.network)
@@ -110,17 +108,37 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert wiki is not None
assert len(wiki) >= 1
- def test_track_get_duration(self) -> None:
+ def test_track_get_duration(self):
# Arrange
- track = pylast.Track("Daft Punk", "Something About Us", self.network)
+ track = pylast.Track("Nirvana", "Lithium", self.network)
# Act
duration = track.get_duration()
# Assert
- assert duration >= 100000
+ assert duration >= 200000
- def test_track_get_album(self) -> None:
+ def test_track_is_streamable(self):
+ # Arrange
+ track = pylast.Track("Nirvana", "Lithium", self.network)
+
+ # Act
+ streamable = track.is_streamable()
+
+ # Assert
+ assert not streamable
+
+ def test_track_is_fulltrack_available(self):
+ # Arrange
+ track = pylast.Track("Nirvana", "Lithium", self.network)
+
+ # Act
+ fulltrack_available = track.is_fulltrack_available()
+
+ # Assert
+ assert not fulltrack_available
+
+ def test_track_get_album(self):
# Arrange
track = pylast.Track("Nirvana", "Lithium", self.network)
@@ -130,7 +148,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert str(album) == "Nirvana - Nevermind"
- def test_track_get_similar(self) -> None:
+ def test_track_get_similar(self):
# Arrange
track = pylast.Track("Cher", "Believe", self.network)
@@ -138,10 +156,14 @@ class TestPyLastTrack(TestPyLastWithLastFm):
similar = track.get_similar()
# 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
- def test_track_get_similar_limits(self) -> None:
+ def test_track_get_similar_limits(self):
# Arrange
track = pylast.Track("Cher", "Believe", self.network)
@@ -151,7 +173,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert len(track.get_similar(limit=None)) >= 23
assert len(track.get_similar(limit=0)) >= 23
- def test_tracks_notequal(self) -> None:
+ def test_tracks_notequal(self):
# Arrange
track1 = pylast.Track("Test Artist", "test title", self.network)
track2 = pylast.Track("Test Artist", "Test Track", self.network)
@@ -160,7 +182,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert track1 != track2
- def test_track_title_prop_caps(self) -> None:
+ def test_track_title_prop_caps(self):
# Arrange
track = pylast.Track("test artist", "test title", self.network)
@@ -170,7 +192,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert title == "Test Title"
- def test_track_listener_count(self) -> None:
+ def test_track_listener_count(self):
# Arrange
track = pylast.Track("test artist", "test title", self.network)
@@ -180,7 +202,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert count > 21
- def test_album_tracks(self) -> None:
+ def test_album_tracks(self):
# Arrange
album = pylast.Album("Test Artist", "Test", self.network)
@@ -194,7 +216,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
assert len(tracks) == 1
assert url.startswith("https://www.last.fm/music/test")
- def test_track_eq_none_is_false(self) -> None:
+ def test_track_eq_none_is_false(self):
# Arrange
track1 = None
track2 = pylast.Track("Test Artist", "test title", self.network)
@@ -202,7 +224,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Act / Assert
assert track1 != track2
- def test_track_ne_none_is_true(self) -> None:
+ def test_track_ne_none_is_true(self):
# Arrange
track1 = None
track2 = pylast.Track("Test Artist", "test title", self.network)
@@ -210,7 +232,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Act / Assert
assert track1 != track2
- def test_track_get_correction(self) -> None:
+ def test_track_get_correction(self):
# Arrange
track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network)
@@ -220,7 +242,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
# Assert
assert corrected_track_name == "Mr. Brownstone"
- def test_track_with_no_mbid(self) -> None:
+ def test_track_with_no_mbid(self):
# Arrange
track = pylast.Track("Static-X", "Set It Off", self.network)
diff --git a/tests/test_user.py b/tests/test_user.py
index f5069d5..5f68262 100755
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
-from __future__ import annotations
-
import calendar
import datetime as dt
import inspect
@@ -18,7 +16,7 @@ from .test_pylast import TestPyLastWithLastFm
class TestPyLastUser(TestPyLastWithLastFm):
- def test_repr(self) -> None:
+ def test_repr(self):
# Arrange
user = self.network.get_user("RJ")
@@ -26,9 +24,9 @@ class TestPyLastUser(TestPyLastWithLastFm):
representation = repr(user)
# Assert
- assert representation.startswith("pylast.User('RJ',")
+ self.assert_startswith(representation, "pylast.User('RJ',")
- def test_str(self) -> None:
+ def test_str(self):
# Arrange
user = self.network.get_user("RJ")
@@ -38,7 +36,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert string == "RJ"
- def test_equality(self) -> None:
+ def test_equality(self):
# Arrange
user_1a = self.network.get_user("RJ")
user_1b = self.network.get_user("RJ")
@@ -50,7 +48,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert user_1a != user_2
assert user_1a != not_a_user
- def test_get_name(self) -> None:
+ def test_get_name(self):
# Arrange
user = self.network.get_user("RJ")
@@ -60,7 +58,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert name == "RJ"
- def test_get_user_registration(self) -> None:
+ def test_get_user_registration(self):
# Arrange
user = self.network.get_user("RJ")
@@ -76,7 +74,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Just check date because of timezones
assert "2002-11-20 " in registered
- def test_get_user_unixtime_registration(self) -> None:
+ def test_get_user_unixtime_registration(self):
# Arrange
user = self.network.get_user("RJ")
@@ -87,7 +85,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Just check date because of timezones
assert unixtime_registered == 1037793040
- def test_get_countryless_user(self) -> None:
+ def test_get_countryless_user(self):
# Arrange
# Currently test_user has no country set:
lastfm_user = self.network.get_user("test_user")
@@ -98,7 +96,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert country is None
- def test_user_get_country(self) -> None:
+ def test_user_get_country(self):
# Arrange
lastfm_user = self.network.get_user("RJ")
@@ -108,7 +106,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert str(country) == "United Kingdom"
- def test_user_equals_none(self) -> None:
+ def test_user_equals_none(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
@@ -118,7 +116,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert not value
- def test_user_not_equal_to_none(self) -> None:
+ def test_user_not_equal_to_none(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
@@ -128,7 +126,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert value
- def test_now_playing_user_with_no_scrobbles(self) -> None:
+ def test_now_playing_user_with_no_scrobbles(self):
# Arrange
# Currently test-account has no scrobbles:
user = self.network.get_user("test-account")
@@ -139,7 +137,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert current_track is None
- def test_love_limits(self) -> None:
+ def test_love_limits(self):
# Arrange
# Currently test-account has at least 23 loved tracks:
user = self.network.get_user("test-user")
@@ -150,7 +148,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert len(user.get_loved_tracks(limit=None)) >= 23
assert len(user.get_loved_tracks(limit=0)) >= 23
- def test_user_is_hashable(self) -> None:
+ def test_user_is_hashable(self):
# Arrange
user = self.network.get_user(self.username)
@@ -171,7 +169,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# # Assert
# self.assertGreaterEqual(len(tracks), 0)
- def test_pickle(self) -> None:
+ def test_pickle(self):
# Arrange
import pickle
@@ -189,7 +187,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert lastfm_user == loaded_user
@pytest.mark.xfail
- def test_cacheable_user(self) -> None:
+ def test_cacheable_user(self):
# Arrange
lastfm_user = self.network.get_authenticated_user()
@@ -203,7 +201,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
lastfm_user, "get_recent_tracks"
)
- def test_user_get_top_tags_with_limit(self) -> None:
+ def test_user_get_top_tags_with_limit(self):
# Arrange
user = self.network.get_user("RJ")
@@ -213,7 +211,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
- def test_user_top_tracks(self) -> None:
+ def test_user_top_tracks(self):
# Arrange
lastfm_user = self.network.get_user("RJ")
@@ -223,14 +221,14 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
- def helper_assert_chart(self, chart, expected_type) -> None:
+ def helper_assert_chart(self, chart, expected_type):
# Assert
assert chart is not None
assert len(chart) > 0
assert isinstance(chart[0], pylast.TopItem)
assert isinstance(chart[0].item, expected_type)
- def helper_get_assert_charts(self, thing, date) -> None:
+ def helper_get_assert_charts(self, thing, date):
# Arrange
album_chart, track_chart = None, None
(from_date, to_date) = date
@@ -247,14 +245,14 @@ class TestPyLastUser(TestPyLastWithLastFm):
self.helper_assert_chart(album_chart, pylast.Album)
self.helper_assert_chart(track_chart, pylast.Track)
- def helper_dates_valid(self, dates) -> None:
+ def helper_dates_valid(self, dates):
# Assert
assert len(dates) >= 1
assert isinstance(dates[0], tuple)
(start, end) = dates[0]
assert start < end
- def test_user_charts(self) -> None:
+ def test_user_charts(self):
# Arrange
lastfm_user = self.network.get_user("RJ")
dates = lastfm_user.get_weekly_chart_dates()
@@ -263,7 +261,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Act/Assert
self.helper_get_assert_charts(lastfm_user, dates[0])
- def test_user_top_artists(self) -> None:
+ def test_user_top_artists(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
@@ -273,7 +271,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
- def test_user_top_albums(self) -> None:
+ def test_user_top_albums(self):
# Arrange
user = self.network.get_user("RJ")
@@ -287,7 +285,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert len(top_album.info["image"])
assert re.search(r"^http.+$", top_album.info["image"][pylast.SIZE_LARGE])
- def test_user_tagged_artists(self) -> None:
+ def test_user_tagged_artists(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["artisttagola"]
@@ -300,7 +298,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_list(artists, pylast.Artist)
- def test_user_tagged_albums(self) -> None:
+ def test_user_tagged_albums(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["albumtagola"]
@@ -313,7 +311,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_list(albums, pylast.Album)
- def test_user_tagged_tracks(self) -> None:
+ def test_user_tagged_tracks(self):
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["tracktagola"]
@@ -326,7 +324,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
self.helper_only_one_thing_in_list(tracks, pylast.Track)
- def test_user_subscriber(self) -> None:
+ def test_user_subscriber(self):
# Arrange
subscriber = self.network.get_user("RJ")
non_subscriber = self.network.get_user("Test User")
@@ -339,7 +337,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert subscriber_is_subscriber
assert not non_subscriber_is_subscriber
- def test_user_get_image(self) -> None:
+ def test_user_get_image(self):
# Arrange
user = self.network.get_user("RJ")
@@ -347,9 +345,9 @@ class TestPyLastUser(TestPyLastWithLastFm):
url = user.get_image()
# Assert
- assert url.startswith("https://")
+ self.assert_startswith(url, "https://")
- def test_user_get_library(self) -> None:
+ def test_user_get_library(self):
# Arrange
user = self.network.get_user(self.username)
@@ -359,7 +357,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert isinstance(library, pylast.Library)
- def test_get_recent_tracks_from_to(self) -> None:
+ def test_get_recent_tracks_from_to(self):
# Arrange
lastfm_user = self.network.get_user("RJ")
start = dt.datetime(2011, 7, 21, 15, 10)
@@ -376,7 +374,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert str(tracks[0].track.artist) == "Johnny Cash"
assert str(tracks[0].track.title) == "Ring of Fire"
- def test_get_recent_tracks_limit_none(self) -> None:
+ def test_get_recent_tracks_limit_none(self):
# Arrange
lastfm_user = self.network.get_user("bbc6music")
start = dt.datetime(2020, 2, 15, 15, 00)
@@ -395,7 +393,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert str(tracks[0].track.artist) == "Seun Kuti & Egypt 80"
assert str(tracks[0].track.title) == "Struggles Sounds"
- def test_get_recent_tracks_is_streamable(self) -> None:
+ def test_get_recent_tracks_is_streamable(self):
# Arrange
lastfm_user = self.network.get_user("bbc6music")
start = dt.datetime(2020, 2, 15, 15, 00)
@@ -412,7 +410,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert inspect.isgenerator(tracks)
- def test_get_playcount(self) -> None:
+ def test_get_playcount(self):
# Arrange
user = self.network.get_user("RJ")
@@ -422,7 +420,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert playcount >= 128387
- def test_get_image(self) -> None:
+ def test_get_image(self):
# Arrange
user = self.network.get_user("RJ")
@@ -430,10 +428,10 @@ class TestPyLastUser(TestPyLastWithLastFm):
image = user.get_image()
# Assert
- assert image.startswith("https://")
- assert image.endswith(".png")
+ self.assert_startswith(image, "https://")
+ self.assert_endswith(image, ".png")
- def test_get_url(self) -> None:
+ def test_get_url(self):
# Arrange
user = self.network.get_user("RJ")
@@ -443,7 +441,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
# Assert
assert url == "https://www.last.fm/user/rj"
- def test_get_weekly_artist_charts(self) -> None:
+ def test_get_weekly_artist_charts(self):
# Arrange
user = self.network.get_user("bbc6music")
@@ -455,7 +453,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert artist is not None
assert isinstance(artist.network, pylast.LastFMNetwork)
- def test_get_weekly_track_charts(self) -> None:
+ def test_get_weekly_track_charts(self):
# Arrange
user = self.network.get_user("bbc6music")
@@ -467,7 +465,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert track is not None
assert isinstance(track.network, pylast.LastFMNetwork)
- def test_user_get_track_scrobbles(self) -> None:
+ def test_user_get_track_scrobbles(self):
# Arrange
artist = "France Gall"
title = "Laisse Tomber Les Filles"
@@ -481,7 +479,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
assert str(scrobbles[0].track.artist) == "France Gall"
assert scrobbles[0].track.title == "Laisse Tomber Les Filles"
- def test_cacheable_user_get_track_scrobbles(self) -> None:
+ def test_cacheable_user_get_track_scrobbles(self):
# Arrange
artist = "France Gall"
title = "Laisse Tomber Les Filles"
diff --git a/tests/unicode_test.py b/tests/unicode_test.py
index 67f234b..7b3c271 100644
--- a/tests/unicode_test.py
+++ b/tests/unicode_test.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
from unittest import mock
import pytest
@@ -20,51 +18,12 @@ def mock_network():
"fdasfdsafsaf not unicode",
],
)
-def test_get_cache_key(artist) -> None:
+def test_get_cache_key(artist):
request = pylast._Request(mock_network(), "some_method", params={"artist": artist})
request._get_cache_key()
@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())])
-def test_cast_and_hash(obj) -> None:
- assert isinstance(str(obj), str)
+def test_cast_and_hash(obj):
+ assert type(str(obj)) is str
assert isinstance(hash(obj), int)
-
-
-@pytest.mark.parametrize(
- "test_input, expected",
- [
- (
- # Plain text
- 'test album name',
- 'test album name',
- ),
- (
- # Contains Unicode ENQ Enquiry control character
- 'test album \u0005name',
- 'test album name',
- ),
- ],
-)
-def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None:
- assert pylast._remove_invalid_xml_chars(test_input) == expected
-
-
-@pytest.mark.parametrize(
- "test_input, expected",
- [
- (
- # Plain text
- 'test album name',
- 'test album name',
- ),
- (
- # Contains Unicode ENQ Enquiry control character
- 'test album \u0005name',
- 'test album name',
- ),
- ],
-)
-def test__parse_response(test_input: str, expected: str) -> None:
- doc = pylast._parse_response(test_input)
- assert doc.toxml() == expected
diff --git a/tox.ini b/tox.ini
index 3ead5fc..c19e202 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,40 +1,29 @@
[tox]
-requires =
- tox>=4.2
-env_list =
- lint
- py{py3, 313, 312, 311, 310, 39, 38}
+envlist =
+ py{py3, 310, 39, 38, 37, 36}
[testenv]
-extras =
- tests
-pass_env =
- FORCE_COLOR
+passenv =
PYLAST_API_KEY
PYLAST_API_SECRET
PYLAST_PASSWORD_HASH
PYLAST_USERNAME
+extras =
+ tests
commands =
- {envpython} -m pytest -v -s -W all \
- --cov pylast \
- --cov tests \
- --cov-report html \
- --cov-report term-missing \
- --cov-report xml \
- --random-order \
- {posargs}
-
-[testenv:lint]
-skip_install = true
-deps =
- pre-commit
-pass_env =
- PRE_COMMIT_COLOR
-commands =
- pre-commit run --all-files --show-diff-on-failure
+ pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --random-order {posargs}
[testenv:venv]
deps =
ipdb
commands =
{posargs}
+
+[testenv:lint]
+passenv =
+ PRE_COMMIT_COLOR
+skip_install = true
+deps =
+ pre-commit
+commands =
+ pre-commit run --all-files --show-diff-on-failure