Compare commits

..

3 commits

Author SHA1 Message Date
Hugo van Kemenade ddc80fc5c3 Update copyright year 2022-08-29 14:34:43 +03:00
Hugo van Kemenade 09fcc776a7 Refactor exceptions into package 2022-08-29 14:30:08 +03:00
Hugo van Kemenade 620323eab0 Refactor helper functions into a utils package 2022-08-29 14:28:22 +03:00
33 changed files with 624 additions and 734 deletions

View file

@ -22,11 +22,6 @@ categories:
exclude-labels:
- "changelog: skip"
autolabeler:
- label: "changelog: skip"
branch:
- "/pre-commit-ci-update-config/"
template: |
$CHANGES

13
.github/renovate.json vendored
View file

@ -1,13 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"],
"labels": ["changelog: skip", "dependencies"],
"extends": [
"config:base"
],
"labels": [
"changelog: skip",
"dependencies"
],
"packageRules": [
{
"groupName": "github-actions",
"matchManagers": ["github-actions"],
"separateMajorMinor": "false"
}
],
"schedule": ["on the first day of the month"]
]
}

View file

@ -2,74 +2,51 @@ name: Deploy
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
branches:
- main
release:
types:
- published
workflow_dispatch:
permissions:
contents: read
jobs:
# Always build & lint package.
build-package:
name: Build & verify package
deploy:
if: github.repository_owner == 'pylast'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
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: Set up Python
uses: actions/setup-python@v4
with:
name: Packages
path: dist
python-version: "3.10"
cache: pip
cache-dependency-path: setup.cfg
- name: Upload package to Test PyPI
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U build twine wheel
- name: Build package
run: |
python setup.py --version
python -m build
twine check --strict dist/*
- name: Publish package to PyPI
if: github.event.action == 'published'
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
user: __token__
password: ${{ secrets.pypi_password }}
# 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
permissions:
id-token: write
steps:
- name: Download packages built by build-and-inspect-python-package
uses: actions/download-artifact@v4
with:
name: Packages
path: dist
- name: Upload package to PyPI
- name: Publish package to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.test_pypi_password }}
repository_url: https://test.pypi.org/legacy/

View file

@ -1,8 +1,5 @@
name: Sync labels
permissions:
pull-requests: write
on:
push:
branches:
@ -15,7 +12,7 @@ jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: micnncim/action-label-syncer@v1
with:
prune: false

View file

@ -2,21 +2,13 @@ name: Lint
on: [push, pull_request, workflow_dispatch]
env:
FORCE_COLOR: 1
PIP_DISABLE_PIP_VERSION_CHECK: 1
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.x"
cache: pip
- uses: pre-commit/action@v3.0.1
- uses: pre-commit/action@v3.0.0

View file

@ -5,30 +5,14 @@ on:
# 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
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
runs-on: ubuntu-latest
steps:
# Drafts your next release notes as pull requests are merged into "main"
- uses: release-drafter/release-drafter@v6
- uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -8,12 +8,8 @@ jobs:
label:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: mheap/github-action-required-labels@v5
- uses: mheap/github-action-required-labels@v2
with:
mode: minimum
count: 1

View file

@ -11,18 +11,18 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["pypy3.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["pypy-3.8", "3.7", "3.8", "3.9", "3.10", "3.11-dev"]
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: pip
cache-dependency-path: setup.cfg
- name: Install dependencies
run: |
@ -40,7 +40,7 @@ jobs:
PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }}
- name: Upload coverage
uses: codecov/codecov-action@v3.1.5
uses: codecov/codecov-action@v3
with:
flags: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
@ -48,7 +48,7 @@ jobs:
success:
needs: test
runs-on: ubuntu-latest
name: Test successful
name: test successful
steps:
- name: Success
run: echo Test successful

View file

@ -1,74 +1,56 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
- repo: https://github.com/asottile/pyupgrade
rev: v2.34.0
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
- repo: https://github.com/psf/black
rev: 22.6.0
hooks:
- id: black
args: [--target-version=py37]
- repo: https://github.com/asottile/blacken-docs
rev: 1.18.0
rev: v1.12.1
hooks:
- id: blacken-docs
args: [--target-version=py38]
additional_dependencies: [black]
args: [--target-version=py37]
additional_dependencies: [black==21.12b0]
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/PyCQA/flake8
rev: 4.0.1
hooks:
- id: flake8
additional_dependencies: [flake8-2020, flake8-implicit-str-concat]
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.9.0
hooks:
- id: python-check-blanket-noqa
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v4.3.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
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.28.6
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.20.1
hooks:
- id: check-github-workflows
- id: check-renovate
- repo: https://github.com/rhysd/actionlint
rev: v1.7.1
hooks:
- id: actionlint
- repo: https://github.com/tox-dev/pyproject-fmt
rev: 2.1.3
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.18
hooks:
- id: validate-pyproject
- id: setup-cfg-fmt
args: [--max-py-version=3.11]
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.3.1
rev: 0.5.2
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

9
.scrutinizer.yml Normal file
View file

@ -0,0 +1,9 @@
checks:
python:
code_rating: true
duplicate_code: true
filter:
excluded_paths:
- '*/test/*'
tools:
external_code_coverage: true

View file

@ -12,125 +12,117 @@ See GitHub Releases:
## 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.
* `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

View file

@ -5,7 +5,7 @@
[![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast)
[![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions)
[![Coverage (Codecov)](https://codecov.io/gh/pylast/pylast/branch/main/graph/badge.svg)](https://codecov.io/gh/pylast/pylast)
[![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black)
[![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088)
A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites
@ -15,44 +15,48 @@ Use the pydoc utility for help on usage or see [tests/](tests/) for examples.
## 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 https://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 5.0+ supports Python 3.7-3.10.
* pyLast 4.3+ 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.
## 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
@ -79,43 +83,7 @@ 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
track = network.get_track("Iron Maiden", "The Nomad")
track.love()
@ -136,9 +104,8 @@ integration and unit tests with Last.fm, and plenty of code examples.
For integration tests you need a test account at Last.fm that will become cluttered with
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

View file

@ -1,8 +1,8 @@
# 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`.
[GitHub Actions](https://github.com/pylast/pylast/actions) should be running cleanly for
all merges to `main`.
[![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions)
- [ ] Edit release draft, adjust text if needed:
@ -12,8 +12,7 @@
- [ ] Publish release
- [ ] Check the tagged
[GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
- [ ] Check the tagged [GitHub Actions build](https://github.com/pylast/pylast/actions/workflows/deploy.yml)
has deployed to [PyPI](https://pypi.org/project/pylast/#history)
- [ ] Check installation:

View file

@ -1,97 +0,0 @@
[build-system]
build-backend = "hatchling.build"
requires = [
"hatch-vcs",
"hatchling",
]
[project]
name = "pylast"
description = "A Python interface to Last.fm and Libre.fm"
readme = "README.md"
keywords = [
"Last.fm",
"music",
"scrobble",
"scrobbling",
]
license = { text = "Apache-2.0" }
maintainers = [
{ name = "Hugo van Kemenade" },
]
authors = [
{ name = "Amr Hassan <amr.hassan@gmail.com> and Contributors", email = "amr.hassan@gmail.com" },
]
requires-python = ">=3.8"
classifiers = [
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dynamic = [
"version",
]
dependencies = [
"httpx",
]
optional-dependencies.tests = [
"flaky",
"pytest",
"pytest-cov",
"pytest-random-order",
"pyyaml",
]
urls.Changelog = "https://github.com/pylast/pylast/releases"
urls.Homepage = "https://github.com/pylast/pylast"
urls.Source = "https://github.com/pylast/pylast"
[tool.hatch]
version.source = "vcs"
[tool.hatch.version.raw-options]
local_scheme = "no-local-version"
[tool.ruff]
fix = true
lint.select = [
"C4", # flake8-comprehensions
"E", # pycodestyle errors
"EM", # flake8-errmsg
"F", # pyflakes errors
"I", # isort
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"PGH", # pygrep-hooks
"RUF022", # unsorted-dunder-all
"RUF100", # unused noqa (yesqa)
"UP", # pyupgrade
"W", # pycodestyle warnings
"YTT", # flake8-2020
]
lint.extend-ignore = [
"E203", # Whitespace before ':'
"E221", # Multiple spaces before operator
"E226", # Missing whitespace around arithmetic operator
"E241", # Multiple spaces after ','
]
lint.isort.known-first-party = [
"pylast",
]
lint.isort.required-imports = [
"from __future__ import annotations",
]
[tool.pyproject-fmt]
max_supported_python = "3.13"

58
setup.cfg Normal file
View file

@ -0,0 +1,58 @@
[metadata]
name = pylast
description = A Python interface to Last.fm and Libre.fm
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/pylast/pylast
author = Amr Hassan <amr.hassan@gmail.com> and Contributors
author_email = amr.hassan@gmail.com
maintainer = Hugo van Kemenade
license = Apache-2.0
license_file = LICENSE.txt
classifiers =
Development Status :: 5 - Production/Stable
License :: OSI Approved :: Apache Software License
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: Implementation :: PyPy
Topic :: Internet
Topic :: Multimedia :: Sound/Audio
Topic :: Software Development :: Libraries :: Python Modules
keywords =
Last.fm
music
scrobble
scrobbling
[options]
packages = find:
install_requires =
httpx
importlib-metadata;python_version < '3.8'
python_requires = >=3.7
package_dir = =src
setup_requires =
setuptools-scm
[options.packages.find]
where = src
[options.extras_require]
tests =
flaky
pytest
pytest-cov
pytest-random-order
pyyaml
[flake8]
max_line_length = 88
[tool:isort]
profile = black

12
setup.py Executable file
View file

@ -0,0 +1,12 @@
from setuptools import setup
def local_scheme(version: str) -> str:
"""Skip the local version (eg. +xyz of 0.6.1.dev4+gdf99fe2)
to be able to upload to Test PyPI"""
return ""
setup(
use_scm_version={"local_scheme": local_scheme},
)

View file

@ -1,9 +1,9 @@
#
# 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
# Copyright 2013-2022 hugovk
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -22,27 +22,52 @@ from __future__ import annotations
import collections
import 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 urllib.parse import quote_plus
from xml.dom import Node, minidom
from xml.dom import minidom
import httpx
try:
# Python 3.8+
import importlib.metadata as importlib_metadata
except ImportError:
# Python 3.7 and lower
import importlib_metadata # type: ignore
from .exceptions import MalformedResponseError, NetworkError, PyLastError, WSError
from .utils import (
_collect_nodes,
_number,
_parse_response,
_string_output,
_unescape_htmlentity,
_unicode,
_url_safe,
cleanup_nodes,
md5,
)
__author__ = "Amr Hassan, hugovk, Mice Pápai"
__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2021 hugovk, 2017 Mice Pápai"
__copyright__ = "Copyright (C) 2008-2010 Amr Hassan, 2013-2022 hugovk, 2017 Mice Pápai"
__license__ = "apache2"
__email__ = "amr.hassan@gmail.com"
__version__ = importlib.metadata.version(__name__)
__version__ = importlib_metadata.version(__name__)
__all__ = [
# Exceptions
MalformedResponseError,
NetworkError,
PyLastError,
WSError,
# Utils
cleanup_nodes,
md5,
]
# 1 : This error does not exist
STATUS_INVALID_SERVICE = 2
@ -529,25 +554,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.
@ -593,6 +619,7 @@ 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"]
@ -613,6 +640,7 @@ 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]
@ -628,6 +656,7 @@ class _Network:
class LastFMNetwork(_Network):
"""A Last.fm network object
api_key: a provided API_KEY
@ -705,7 +734,7 @@ class LastFMNetwork(_Network):
class LibreFMNetwork(_Network):
"""
A preconfigured _Network object for music.lonestar.it
A preconfigured _Network object for Libre.fm
api_key: a provided API_KEY
api_secret: a provided API_SECRET
@ -726,28 +755,29 @@ class LibreFMNetwork(_Network):
username: str = "",
password_hash: str = "",
) -> None:
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",
@ -893,7 +923,6 @@ class _Request:
username = "" if username is None else f"?username={username}"
(host_name, host_subdir) = self.network.ws_server
timeout = httpx.Timeout(5, read=10)
if self.network.is_proxy_enabled():
client = httpx.Client(
@ -901,14 +930,12 @@ class _Request:
base_url=f"https://{host_name}",
headers=HEADERS,
proxies=self.network.proxy,
timeout=timeout,
)
else:
client = httpx.Client(
verify=SSL_CONTEXT,
base_url=f"https://{host_name}",
headers=HEADERS,
timeout=timeout,
)
try:
@ -930,7 +957,7 @@ class _Request:
client.close()
return response_text
def execute(self, cacheable: bool = False) -> xml.dom.minidom.Document:
def execute(self, cacheable: bool = False) -> minidom.Document:
"""Returns the XML DOM response of the POST Request from the server"""
if self.network.is_caching_enabled() and cacheable:
@ -1081,13 +1108,6 @@ Image = collections.namedtuple(
)
def _string_output(func):
def r(*args):
return str(func(*args))
return r
class _BaseObject:
"""An abstract webservices object."""
@ -1158,7 +1178,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 +1192,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")
@ -1242,10 +1262,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:
@ -1357,11 +1375,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)
@ -1387,81 +1405,6 @@ class _Taggable(_BaseObject):
return seq
class PyLastError(Exception):
"""Generic exception raised by PyLast"""
pass
class WSError(PyLastError):
"""Exception related to the Network web service"""
def __init__(self, network, status, details) -> None:
self.status = status
self.details = details
self.network = network
@_string_output
def __str__(self) -> str:
return self.details
def get_id(self):
"""Returns the exception ID, from one of the following:
STATUS_INVALID_SERVICE = 2
STATUS_INVALID_METHOD = 3
STATUS_AUTH_FAILED = 4
STATUS_INVALID_FORMAT = 5
STATUS_INVALID_PARAMS = 6
STATUS_INVALID_RESOURCE = 7
STATUS_OPERATION_FAILED = 8
STATUS_INVALID_SK = 9
STATUS_INVALID_API_KEY = 10
STATUS_OFFLINE = 11
STATUS_SUBSCRIBERS_ONLY = 12
STATUS_TOKEN_UNAUTHORIZED = 14
STATUS_TOKEN_EXPIRED = 15
STATUS_TEMPORARILY_UNAVAILABLE = 16
STATUS_LOGIN_REQUIRED = 17
STATUS_TRIAL_EXPIRED = 18
STATUS_NOT_ENOUGH_CONTENT = 20
STATUS_NOT_ENOUGH_MEMBERS = 21
STATUS_NOT_ENOUGH_FANS = 22
STATUS_NOT_ENOUGH_NEIGHBOURS = 23
STATUS_NO_PEAK_RADIO = 24
STATUS_RADIO_NOT_FOUND = 25
STATUS_API_KEY_SUSPENDED = 26
STATUS_DEPRECATED = 27
STATUS_RATE_LIMIT_EXCEEDED = 29
"""
return self.status
class MalformedResponseError(PyLastError):
"""Exception conveying a malformed response from the music network."""
def __init__(self, network, underlying_error) -> None:
self.network = network
self.underlying_error = underlying_error
def __str__(self) -> str:
return (
f"Malformed response from {self.network.name}. "
f"Underlying error: {self.underlying_error}"
)
class NetworkError(PyLastError):
"""Exception conveying a problem in sending a request to Last.fm"""
def __init__(self, network, underlying_error) -> None:
self.network = network
self.underlying_error = underlying_error
def __str__(self) -> str:
return f"NetworkError: {self.underlying_error}"
class _Opus(_Taggable):
"""An album or track."""
@ -1509,7 +1452,7 @@ class _Opus(_Taggable):
return f"{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()
@ -1547,7 +1490,7 @@ class _Opus(_Taggable):
return self.info["image"][size]
def get_title(self, properly_capitalized: bool = False):
"""Returns the album or track title."""
"""Returns the artist or track title."""
if properly_capitalized:
self.title = _extract(
self._request(self.ws_prefix + ".getInfo", True), "name"
@ -2299,8 +2242,8 @@ class User(_Chartable):
self,
limit: int = 10,
cacheable: bool = True,
time_from: int | None = None,
time_to: int | None = None,
time_from=None,
time_to=None,
stream: bool = False,
now_playing: bool = False,
):
@ -2311,11 +2254,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,7 +2329,7 @@ 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)
@ -2712,90 +2657,6 @@ class TrackSearch(_Search):
return seq
def md5(text):
"""Returns the md5 hash of a string."""
h = hashlib.md5()
h.update(_unicode(text).encode("utf-8"))
return h.hexdigest()
def _unicode(text):
if isinstance(text, bytes):
return str(text, "utf-8")
else:
return str(text)
def cleanup_nodes(doc):
"""
Remove text nodes containing only whitespace
"""
for node in doc.documentElement.childNodes:
if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace():
doc.documentElement.removeChild(node)
return doc
def _collect_nodes(
limit, sender, method_name, cacheable, params=None, stream: bool = False
):
"""
Returns a sequence of dom.Node objects about as close to limit as possible
"""
if not params:
params = sender._get_params()
def _stream_collect_nodes():
node_count = 0
page = 1
end_of_pages = False
while not end_of_pages and (not limit or (limit and node_count < limit)):
params["page"] = str(page)
tries = 1
while True:
try:
doc = sender._request(method_name, cacheable, params)
break # success
except Exception as e:
if tries >= 3:
raise PyLastError() from e
# Wait and try again
time.sleep(1)
tries += 1
doc = cleanup_nodes(doc)
# break if there are no child nodes
if not doc.documentElement.childNodes:
break
main = doc.documentElement.childNodes[0]
if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"):
total_pages = _number(
main.getAttribute("totalPages") or main.getAttribute("totalpages")
)
else:
msg = "No total pages attribute"
raise PyLastError(msg)
for node in main.childNodes:
if not node.nodeType == xml.dom.Node.TEXT_NODE and (
not limit or (node_count < limit)
):
node_count += 1
yield node
end_of_pages = page >= total_pages
page += 1
return _stream_collect_nodes() if stream else list(_stream_collect_nodes())
def _extract(node, name, index: int = 0):
"""Extracts a value from the xml string"""
@ -2871,51 +2732,3 @@ def _extract_tracks(doc, network):
artist = _extract(node, "name", 1)
seq.append(Track(artist, name, network))
return seq
def _url_safe(text):
"""Does all kinds of tricks on a text to make it safe to use in a URL."""
return quote_plus(quote_plus(str(text))).lower()
def _number(string):
"""
Extracts an int from a string.
Returns a 0 if None or an empty string was passed.
"""
if not string:
return 0
else:
try:
return int(string)
except ValueError:
return float(string)
def _unescape_htmlentity(string):
mapping = html.entities.name2codepoint
for key in mapping:
string = string.replace(f"&{key};", chr(mapping[key]))
return string
def _parse_response(response: str) -> xml.dom.minidom.Document:
response = str(response).replace("opensearch:", "")
try:
doc = minidom.parseString(response)
except xml.parsers.expat.ExpatError:
# Try again. For performance, we only remove when needed in rare cases.
doc = minidom.parseString(_remove_invalid_xml_chars(response))
return doc
def _remove_invalid_xml_chars(string: str) -> str:
return re.sub(
r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string
)
# End of file

78
src/pylast/exceptions.py Normal file
View file

@ -0,0 +1,78 @@
from __future__ import annotations
from .utils import _string_output
class PyLastError(Exception):
"""Generic exception raised by PyLast"""
pass
class WSError(PyLastError):
"""Exception related to the Network web service"""
def __init__(self, network, status, details) -> None:
self.status = status
self.details = details
self.network = network
@_string_output
def __str__(self) -> str:
return self.details
def get_id(self):
"""Returns the exception ID, from one of the following:
STATUS_INVALID_SERVICE = 2
STATUS_INVALID_METHOD = 3
STATUS_AUTH_FAILED = 4
STATUS_INVALID_FORMAT = 5
STATUS_INVALID_PARAMS = 6
STATUS_INVALID_RESOURCE = 7
STATUS_OPERATION_FAILED = 8
STATUS_INVALID_SK = 9
STATUS_INVALID_API_KEY = 10
STATUS_OFFLINE = 11
STATUS_SUBSCRIBERS_ONLY = 12
STATUS_TOKEN_UNAUTHORIZED = 14
STATUS_TOKEN_EXPIRED = 15
STATUS_TEMPORARILY_UNAVAILABLE = 16
STATUS_LOGIN_REQUIRED = 17
STATUS_TRIAL_EXPIRED = 18
STATUS_NOT_ENOUGH_CONTENT = 20
STATUS_NOT_ENOUGH_MEMBERS = 21
STATUS_NOT_ENOUGH_FANS = 22
STATUS_NOT_ENOUGH_NEIGHBOURS = 23
STATUS_NO_PEAK_RADIO = 24
STATUS_RADIO_NOT_FOUND = 25
STATUS_API_KEY_SUSPENDED = 26
STATUS_DEPRECATED = 27
STATUS_RATE_LIMIT_EXCEEDED = 29
"""
return self.status
class MalformedResponseError(PyLastError):
"""Exception conveying a malformed response from the music network."""
def __init__(self, network, underlying_error) -> None:
self.network = network
self.underlying_error = underlying_error
def __str__(self) -> str:
return (
f"Malformed response from {self.network.name}. "
f"Underlying error: {self.underlying_error}"
)
class NetworkError(PyLastError):
"""Exception conveying a problem in sending a request to Last.fm"""
def __init__(self, network, underlying_error) -> None:
self.network = network
self.underlying_error = underlying_error
def __str__(self) -> str:
return f"NetworkError: {self.underlying_error}"

159
src/pylast/utils.py Normal file
View file

@ -0,0 +1,159 @@
from __future__ import annotations
import hashlib
import html
import re
import time
import warnings
import xml
from urllib.parse import quote_plus
from xml.dom import Node, minidom
import pylast
def cleanup_nodes(doc: minidom.Document) -> minidom.Document:
"""
cleanup_nodes is deprecated and will be removed in pylast 6.0
"""
warnings.warn(
"cleanup_nodes is deprecated and will be removed in pylast 6.0",
DeprecationWarning,
stacklevel=2,
)
return _cleanup_nodes(doc)
def md5(text: str) -> str:
"""Returns the md5 hash of a string."""
h = hashlib.md5()
h.update(_unicode(text).encode("utf-8"))
return h.hexdigest()
def _collect_nodes(
limit, sender, method_name, cacheable, params=None, stream: bool = False
):
"""
Returns a sequence of dom.Node objects about as close to limit as possible
"""
if not params:
params = sender._get_params()
def _stream_collect_nodes():
node_count = 0
page = 1
end_of_pages = False
while not end_of_pages and (not limit or (limit and node_count < limit)):
params["page"] = str(page)
tries = 1
while True:
try:
doc = sender._request(method_name, cacheable, params)
break # success
except Exception as e:
if tries >= 3:
raise pylast.PyLastError() from e
# Wait and try again
time.sleep(1)
tries += 1
doc = _cleanup_nodes(doc)
# break if there are no child nodes
if not doc.documentElement.childNodes:
break
main = doc.documentElement.childNodes[0]
if main.hasAttribute("totalPages") or main.hasAttribute("totalpages"):
total_pages = _number(
main.getAttribute("totalPages") or main.getAttribute("totalpages")
)
else:
raise pylast.PyLastError("No total pages attribute")
for node in main.childNodes:
if not node.nodeType == xml.dom.Node.TEXT_NODE and (
not limit or (node_count < limit)
):
node_count += 1
yield node
end_of_pages = page >= total_pages
page += 1
return _stream_collect_nodes() if stream else list(_stream_collect_nodes())
def _cleanup_nodes(doc: minidom.Document) -> minidom.Document:
"""
Remove text nodes containing only whitespace
"""
for node in doc.documentElement.childNodes:
if node.nodeType == Node.TEXT_NODE and node.nodeValue.isspace():
doc.documentElement.removeChild(node)
return doc
def _number(string: str | None) -> float:
"""
Extracts an int from a string.
Returns a 0 if None or an empty string was passed.
"""
if not string:
return 0
else:
try:
return int(string)
except ValueError:
return float(string)
def _parse_response(response: str) -> xml.dom.minidom.Document:
response = str(response).replace("opensearch:", "")
try:
doc = minidom.parseString(response)
except xml.parsers.expat.ExpatError:
# Try again. For performance, we only remove when needed in rare cases.
doc = minidom.parseString(_remove_invalid_xml_chars(response))
return doc
def _remove_invalid_xml_chars(string: str) -> str:
return re.sub(
r"[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFF]+", "", string
)
def _string_output(func):
def r(*args):
return str(func(*args))
return r
def _unescape_htmlentity(string: str) -> str:
mapping = html.entities.name2codepoint
for key in mapping:
string = string.replace(f"&{key};", chr(mapping[key]))
return string
def _unicode(text: bytes | str) -> str:
if isinstance(text, bytes):
return str(text, "utf-8")
else:
return str(text)
def _url_safe(text: str) -> str:
"""Does all kinds of tricks on a text to make it safe to use in a URL."""
return quote_plus(quote_plus(str(text))).lower()

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pylast
from .test_pylast import TestPyLastWithLastFm
@ -96,8 +94,8 @@ class TestPyLastAlbum(TestPyLastWithLastFm):
image = album.get_cover_image()
# Assert
assert image.startswith("https://")
assert image.endswith(".gif") or image.endswith(".png")
self.assert_startswith(image, "https://")
self.assert_endswith(image, ".gif")
def test_mbid(self) -> None:
# Arrange

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pytest
import pylast

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pylast
from .test_pylast import TestPyLastWithLastFm

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pylast
from .test_pylast import TestPyLastWithLastFm
@ -18,7 +16,7 @@ class TestPyLastLibrary(TestPyLastWithLastFm):
representation = repr(library)
# Assert
assert representation.startswith("pylast.Library(")
self.assert_startswith(representation, "pylast.Library(")
def test_str(self) -> None:
# Arrange
@ -28,7 +26,7 @@ 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:
# Arrange

View file

@ -2,17 +2,15 @@
"""
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:
@ -40,4 +38,4 @@ class TestPyLastWithLibreFm:
representation = repr(network)
# Assert
assert representation.startswith("pylast.LibreFMNetwork(")
self.assert_startswith(representation, "pylast.LibreFMNetwork(")

View file

@ -1,9 +1,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import re
import time
@ -333,12 +330,12 @@ 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:
@ -365,12 +362,12 @@ 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:
@ -399,12 +396,12 @@ 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:

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import os
import time
@ -34,17 +32,25 @@ def load_secrets(): # pragma: no cover
return doc
class PyLastTestCase:
def assert_startswith(self, s, prefix, start=None, end=None) -> None:
assert s.startswith(prefix, start, end)
def assert_endswith(self, s, suffix, start=None, end=None) -> None:
assert s.endswith(suffix, start, end)
def _no_xfail_rerun_filter(err, name, test, plugin) -> bool:
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
@ -65,8 +71,7 @@ class TestPyLastWithLastFm:
password_hash=password_hash,
)
@staticmethod
def helper_is_thing_hashable(thing) -> None:
def helper_is_thing_hashable(self, thing) -> None:
# Arrange
things = set()
@ -77,8 +82,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) -> None:
# Assert
assert a is not None
assert b is not None
@ -102,31 +106,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) -> None:
# 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) -> None:
# 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) -> None:
# 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) -> None:
# Assert
assert len(things) == 2
thing1 = things[0]

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pylast
from .test_pylast import TestPyLastWithLastFm

View file

@ -1,9 +1,7 @@
#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import time
import pytest
@ -112,7 +110,7 @@ class TestPyLastTrack(TestPyLastWithLastFm):
def test_track_get_duration(self) -> None:
# Arrange
track = pylast.Track("Daft Punk", "Something About Us", self.network)
track = pylast.Track("Radiohead", "Creep", self.network)
# Act
duration = track.get_duration()
@ -138,7 +136,11 @@ 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:

View file

@ -2,8 +2,6 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import calendar
import datetime as dt
import inspect
@ -26,7 +24,7 @@ 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:
# Arrange
@ -347,7 +345,7 @@ class TestPyLastUser(TestPyLastWithLastFm):
url = user.get_image()
# Assert
assert url.startswith("https://")
self.assert_startswith(url, "https://")
def test_user_get_library(self) -> None:
# Arrange
@ -430,8 +428,8 @@ 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:
# Arrange

View file

@ -1,5 +1,3 @@
from __future__ import annotations
from unittest import mock
import pytest
@ -27,7 +25,7 @@ def test_get_cache_key(artist) -> None:
@pytest.mark.parametrize("obj", [pylast.Artist("B\xe9l", mock_network())])
def test_cast_and_hash(obj) -> None:
assert isinstance(str(obj), str)
assert type(str(obj)) is str
assert isinstance(hash(obj), int)
@ -47,7 +45,7 @@ def test_cast_and_hash(obj) -> None:
],
)
def test__remove_invalid_xml_chars(test_input: str, expected: str) -> None:
assert pylast._remove_invalid_xml_chars(test_input) == expected
assert pylast.utils._remove_invalid_xml_chars(test_input) == expected
@pytest.mark.parametrize(

25
tox.ini
View file

@ -1,35 +1,26 @@
[tox]
requires =
tox>=4.2
env_list =
envlist =
lint
py{py3, 313, 312, 311, 310, 39, 38}
py{py3, 311, 310, 39, 38, 37}
[testenv]
extras =
tests
pass_env =
passenv =
FORCE_COLOR
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}
pytest -v -s -W all --cov pylast --cov tests --cov-report term-missing --cov-report xml --random-order {posargs}
[testenv:lint]
passenv =
PRE_COMMIT_COLOR
skip_install = true
deps =
pre-commit
pass_env =
PRE_COMMIT_COLOR
commands =
pre-commit run --all-files --show-diff-on-failure