Compare commits

..

No commits in common. "main" and "0.5.11" have entirely different histories.
main ... 0.5.11

43 changed files with 3862 additions and 6196 deletions

1
.build Normal file
View file

@ -0,0 +1 @@
11

View file

@ -1,7 +0,0 @@
# Documentation: https://docs.codecov.io/docs/codecov-yaml
codecov:
# Avoid "Missing base report"
# https://github.com/codecov/support/issues/363
# https://docs.codecov.io/v4.3.6/docs/comparing-commits
allow_coverage_offsets: true

View file

@ -1,18 +0,0 @@
# Top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
# Four-space indentation
[*.py]
indent_size = 4
indent_style = space
trim_trailing_whitespace = true
# Two-space indentation
[*.yml]
indent_size = 2

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
github: hugovk

View file

@ -1,22 +0,0 @@
### What did you do?
### What did you expect to happen?
### What actually happened?
### What versions are you using?
* OS:
* Python:
* pylast:
Please include **code** that reproduces the issue.
The [best reproductions](https://stackoverflow.com/help/minimal-reproducible-example)
are
[self-contained scripts](https://ericlippert.com/2014/03/05/how-to-debug-small-programs/)
with minimal dependencies.
```python
# code goes here
```

View file

@ -1,7 +0,0 @@
Fixes #
Changes proposed in this pull request:
*
*
*

111
.github/labels.yml vendored
View file

@ -1,111 +0,0 @@
# Default GitHub labels
- color: d73a4a
description: "Something isn't working"
name: bug
- color: cfd3d7
description: "This issue or pull request already exists"
name: duplicate
- color: a2eeef
description: "New feature or request"
name: enhancement
- color: 7057ff
description: "Good for newcomers"
name: good first issue
- color: 008672
description: "Extra attention is needed"
name: help wanted
- color: e4e669
description: "This doesn't seem right"
name: invalid
- color: d876e3
description: "Further information is requested"
name: question
- color: ffffff
description: "This will not be worked on"
name: wontfix
# Keep a Changelog labels
# https://keepachangelog.com/en/1.0.0/
- color: 0e8a16
description: "For new features"
name: "changelog: Added"
- color: af99e5
description: "For changes in existing functionality"
name: "changelog: Changed"
- color: FFA500
description: "For soon-to-be removed features"
name: "changelog: Deprecated"
- color: 00A800
description: "For any bug fixes"
name: "changelog: Fixed"
- color: ff0000
description: "For now removed features"
name: "changelog: Removed"
- color: 045aa0
description: "In case of vulnerabilities"
name: "changelog: Security"
- color: fbca04
description: "Exclude PR from release draft"
name: "changelog: skip"
# Other labels
- color: e11d21
description: ""
name: Last.fm bug
- color: FFFFFF
description: ""
name: Milestone-0.3
- color: FFFFFF
description: ""
name: Performance
- color: FFFFFF
description: ""
name: Priority-High
- color: FFFFFF
description: ""
name: Priority-Low
- color: FFFFFF
description: ""
name: Priority-Medium
- color: FFFFFF
description: ""
name: Type-Other
- color: FFFFFF
description: ""
name: Type-Patch
- color: FFFFFF
description: ""
name: Usability
- color: 64c1c0
description: ""
name: backwards incompatible
- color: fef2c0
description: ""
name: build
- color: e99695
description: Feature that will be removed in the future
name: deprecation
- color: FFFFFF
description: ""
name: imported
- color: b60205
description: Removal of a feature, usually done in major releases
name: removal
- color: 0366d6
description: "For dependencies"
name: dependencies
- color: 0052cc
description: "Documentation"
name: docs
- color: f4660e
description: ""
name: Hacktoberfest
- color: f4660e
description: "To credit accepted Hacktoberfest PRs"
name: hacktoberfest-accepted
- color: d65e88
description: "Deploy and release"
name: release
- color: fef2c0
description: "Unit tests, linting, CI, etc."
name: test

View file

@ -1,48 +0,0 @@
name-template: "$RESOLVED_VERSION"
tag-template: "$RESOLVED_VERSION"
categories:
- title: "Added"
labels:
- "changelog: Added"
- "enhancement"
- title: "Changed"
label: "changelog: Changed"
- title: "Deprecated"
label: "changelog: Deprecated"
- title: "Removed"
label: "changelog: Removed"
- title: "Fixed"
labels:
- "changelog: Fixed"
- "bug"
- title: "Security"
label: "changelog: Security"
exclude-labels:
- "changelog: skip"
autolabeler:
- label: "changelog: skip"
branch:
- "/pre-commit-ci-update-config/"
template: |
$CHANGES
version-resolver:
major:
labels:
- "changelog: Removed"
minor:
labels:
- "changelog: Added"
- "changelog: Changed"
- "changelog: Deprecated"
- "enhancement"
patch:
labels:
- "changelog: Fixed"
- "bug"
default: minor

13
.github/renovate.json vendored
View file

@ -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"]
}

View file

@ -1,75 +0,0 @@
name: Deploy
on:
push:
branches: [main]
tags: ["*"]
pull_request:
branches: [main]
release:
types:
- published
workflow_dispatch:
permissions:
contents: read
jobs:
# Always build & lint package.
build-package:
name: Build & verify package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
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
with:
name: Packages
path: dist
- name: Upload package to Test PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
# 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
uses: pypa/gh-action-pypi-publish@release/v1

View file

@ -1,23 +0,0 @@
name: Sync labels
permissions:
pull-requests: write
on:
push:
branches:
- main
paths:
- .github/labels.yml
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: micnncim/action-label-syncer@v1
with:
prune: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,22 +0,0 @@
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
with:
python-version: "3.x"
cache: pip
- uses: pre-commit/action@v3.0.1

View file

@ -1,34 +0,0 @@
name: Release drafter
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
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -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"

View file

@ -1,54 +0,0 @@
name: Test
on: [push, pull_request, workflow_dispatch]
env:
FORCE_COLOR: 1
jobs:
test:
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]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
cache: pip
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U wheel
python -m pip install -U tox
- name: Tox tests
run: |
tox -e py
env:
PYLAST_API_KEY: ${{ secrets.PYLAST_API_KEY }}
PYLAST_API_SECRET: ${{ secrets.PYLAST_API_SECRET }}
PYLAST_PASSWORD_HASH: ${{ secrets.PYLAST_PASSWORD_HASH }}
PYLAST_USERNAME: ${{ secrets.PYLAST_USERNAME }}
- name: Upload coverage
uses: codecov/codecov-action@v3.1.5
with:
flags: ${{ matrix.os }}
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
success:
needs: test
runs-on: ubuntu-latest
name: Test successful
steps:
- name: Success
run: echo Test successful

71
.gitignore vendored
View file

@ -1,71 +0,0 @@
# User Credentials
test_pylast.yaml
.envrc
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
.venv/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# JetBrains
.idea/
# Clone Digger
output.html

View file

@ -1,74 +0,0 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.0
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
hooks:
- id: black
- repo: https://github.com/asottile/blacken-docs
rev: 1.18.0
hooks:
- id: blacken-docs
args: [--target-version=py38]
additional_dependencies: [black]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.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
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
- repo: https://github.com/tox-dev/tox-ini-fmt
rev: 1.3.1
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

View file

@ -1,159 +0,0 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 4.2.1 and newer
See GitHub Releases:
- https://github.com/pylast/pylast/releases
## [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
## [4.1.0] - 2021-01-04
## Added
- 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
## Fixed
- 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
## Removed
- Remove deprecated `Artist.get_cover_image`, `User.get_artist_tracks` and
`STATUS_TOKEN_ERROR` (#348) @hugovk
- Drop support for EOL Python 3.5 (#346) @hugovk
## [3.3.0] - 2020-06-25
### Added
- `User.get_now_playing`: Add album and cover image to info (#330) @hugovk
### Changed
- 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
### Fixed
- 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
## [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])
### 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
### Deprecated
- 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.
## [3.1.0] - 2019-03-07
### Added
- Extract username from session via new
`SessionKeyGenerator.get_web_auth_session_key_username` ([#290])
- `User.get_track_scrobbles` ([#298])
### Deprecated
- `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])
### Removed
- 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])
## [2.4.0] - 2018-08-08
### Deprecated
- 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
[4.0.0]: https://github.com/pylast/pylast/compare/3.3.0...4.0.0
[3.3.0]: https://github.com/pylast/pylast/compare/3.2.1...3.3.0
[3.2.1]: https://github.com/pylast/pylast/compare/3.2.0...3.2.1
[3.2.0]: https://github.com/pylast/pylast/compare/3.1.0...3.2.0
[3.1.0]: https://github.com/pylast/pylast/compare/3.0.0...3.1.0
[3.0.0]: https://github.com/pylast/pylast/compare/2.4.0...3.0.0
[2.4.0]: https://github.com/pylast/pylast/compare/2.3.0...2.4.0
[#265]: https://github.com/pylast/pylast/issues/265
[#273]: https://github.com/pylast/pylast/issues/273
[#282]: https://github.com/pylast/pylast/pull/282
[#290]: https://github.com/pylast/pylast/pull/290
[#297]: https://github.com/pylast/pylast/issues/297
[#298]: https://github.com/pylast/pylast/issues/298
[#301]: https://github.com/pylast/pylast/issues/301
[#305]: https://github.com/pylast/pylast/issues/305
[#307]: https://github.com/pylast/pylast/issues/307
[#310]: https://github.com/pylast/pylast/issues/310
[#311]: https://github.com/pylast/pylast/issues/311
[#312]: https://github.com/pylast/pylast/issues/312
[#316]: https://github.com/pylast/pylast/issues/316
[#346]: https://github.com/pylast/pylast/issues/346
[#347]: https://github.com/pylast/pylast/issues/347
[#348]: https://github.com/pylast/pylast/issues/348

View file

@ -1,6 +1,6 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

4
INSTALL Normal file
View file

@ -0,0 +1,4 @@
Installation Instructions
=========================
Execute "python setup.py install" as a super user.

View file

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
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:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) 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
(d) 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.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

7
README Normal file
View file

@ -0,0 +1,7 @@
pylast
------
A python interface to Last.fm. Try using the pydoc utility for help
on usage.
For more info check out the project's home page at http://code.google.com/p/pylast/
or the mailing list http://groups.google.com/group/pylast/

193
README.md
View file

@ -1,193 +0,0 @@
# pyLast
[![PyPI version](https://img.shields.io/pypi/v/pylast.svg)](https://pypi.org/project/pylast/)
[![Supported Python versions](https://img.shields.io/pypi/pyversions/pylast.svg)](https://pypi.org/project/pylast/)
[![PyPI downloads](https://img.shields.io/pypi/dm/pylast.svg)](https://pypistats.org/packages/pylast)
[![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)
[![DOI](https://zenodo.org/badge/7803088.svg)](https://zenodo.org/badge/latestdoi/7803088)
A Python interface to [Last.fm](https://www.last.fm/) and other API-compatible websites
such as [Libre.fm](https://libre.fm/).
Use the pydoc utility for help on usage or see [tests/](tests/) for examples.
## Installation
Install latest development version:
```sh
python3 -m pip install -U git+https://git.hirad.it/Hirad/pylast
```
Or from requirements.txt:
```txt
-e https://git.hirad.it/Hirad/pylast#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.
## 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.
## 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
Last.fm or any other API-compatible one. You can obtain a pre-configured one for Last.fm
and use it as follows:
```python
import pylast
# You have to have your own unique two values for API_KEY and API_SECRET
# Obtain yours from https://www.last.fm/api/account/create for Last.fm
API_KEY = "b25b959554ed76058ac220b7b2e0a026" # this is a sample key
API_SECRET = "425b55975eed76058ac220b7b4e8a054"
# In order to perform a write operation you need to authenticate yourself
username = "your_user_name"
password_hash = pylast.md5("your_password")
network = pylast.LastFMNetwork(
api_key=API_KEY,
api_secret=API_SECRET,
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()
track.add_tags(("awesome", "favorite"))
# Type help(pylast.LastFMNetwork) or help(pylast) in a Python interpreter
# to get more help about anything and see examples of how it works
```
More examples in
<a href="https://github.com/hugovk/lastfm-tools">hugovk/lastfm-tools</a> and
[tests/](https://github.com/pylast/pylast/tree/main/tests).
## Testing
The [tests/](https://github.com/pylast/pylast/tree/main/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:
```sh
export PYLAST_USERNAME=TODO_ENTER_YOURS_HERE
export PYLAST_PASSWORD_HASH=TODO_ENTER_YOURS_HERE
export PYLAST_API_KEY=TODO_ENTER_YOURS_HERE
export PYLAST_API_SECRET=TODO_ENTER_YOURS_HERE
```
To run all unit and integration tests:
```sh
python3 -m pip install -e ".[tests]"
pytest
```
Or run just one test case:
```sh
pytest -k test_scrobble
```
To run with coverage:
```sh
pytest -v --cov pylast --cov-report term-missing
coverage report # for command-line report
coverage html # for HTML report
open htmlcov/index.html
```
## Logging
To enable from your own code:
```python
import logging
import pylast
logging.basicConfig(level=logging.INFO)
network = pylast.LastFMNetwork(...)
```
To enable from pytest:
```sh
pytest --log-cli-level info -k test_album_search_images
```
To also see data returned from the API, use `level=logging.DEBUG` or
`--log-cli-level debug` instead.

View file

@ -1,23 +0,0 @@
# 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`.
[![Test](https://github.com/pylast/pylast/workflows/Test/badge.svg)](https://github.com/pylast/pylast/actions)
- [ ] Edit release draft, adjust text if needed:
https://github.com/pylast/pylast/releases
- [ ] Check next tag is correct, amend if needed
- [ ] Publish release
- [ ] 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:
```bash
pip3 uninstall -y pylast && pip3 install -U pylast && python3 -c "import pylast; print(pylast.__version__)"
```

View file

@ -1,4 +0,0 @@
username: TODO_ENTER_YOURS_HERE
password_hash: TODO_ENTER_YOURS_HERE
api_key: TODO_ENTER_YOURS_HERE
api_secret: TODO_ENTER_YOURS_HERE

3814
pylast.py Normal file

File diff suppressed because it is too large Load diff

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"

View file

@ -1,6 +0,0 @@
[pytest]
filterwarnings =
once::DeprecationWarning
once::PendingDeprecationWarning
xfail_strict=true

32
setup.py Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env python
from distutils.core import setup
import os
def get_build():
path = "./.build"
if os.path.exists(path):
fp = open(path, "r")
build = eval(fp.read())
if os.path.exists("./.increase_build"):
build += 1
fp.close()
else:
build = 1
fp = open(path, "w")
fp.write(str(build))
fp.close()
return str(build)
setup(name = "pylast",
version = "0.5." + get_build(),
author = "Amr Hassan <amr.hassan@gmail.com>",
description = "A Python interface to Last.fm (and other API compatible social networks)",
author_email = "amr.hassan@gmail.com",
url = "http://code.google.com/p/pylast/",
py_modules = ("pylast",),
license = "Apache2"
)

File diff suppressed because it is too large Load diff

View file

View file

@ -1,120 +0,0 @@
#!/usr/bin/env python
"""
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:
# Arrange
album = self.network.get_album("Test Artist", "Test Album")
# Act
tags = album.get_top_tags(limit=1)
# Assert
assert len(tags) > 0
assert isinstance(tags[0], pylast.TopItem)
def test_album_is_hashable(self) -> None:
# 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:
# Arrange
lastfm_user = self.network.get_user(self.username)
# Act
# limit=2 to ignore now-playing:
track = list(lastfm_user.get_recent_tracks(limit=2))[0]
# Assert
assert hasattr(track, "album")
def test_album_wiki_content(self) -> None:
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
# Act
wiki = album.get_wiki_content()
# Assert
assert wiki is not None
assert len(wiki) >= 1
def test_album_wiki_published_date(self) -> None:
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
# Act
wiki = album.get_wiki_published_date()
# Assert
assert wiki is not None
assert len(wiki) >= 1
def test_album_wiki_summary(self) -> None:
# Arrange
album = pylast.Album("Test Artist", "Test Album", self.network)
# Act
wiki = album.get_wiki_summary()
# Assert
assert wiki is not None
assert len(wiki) >= 1
def test_album_eq_none_is_false(self) -> None:
# Arrange
album1 = None
album2 = pylast.Album("Test Artist", "Test Album", self.network)
# Act / Assert
assert album1 != album2
def test_album_ne_none_is_true(self) -> None:
# Arrange
album1 = None
album2 = pylast.Album("Test Artist", "Test Album", self.network)
# Act / Assert
assert album1 != album2
def test_get_cover_image(self) -> None:
# Arrange
album = self.network.get_album("Test Artist", "Test Album")
# Act
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

View file

@ -1,278 +0,0 @@
#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import pytest
import pylast
from .test_pylast import WRITE_TEST, TestPyLastWithLastFm
class TestPyLastArtist(TestPyLastWithLastFm):
def test_repr(self) -> None:
# Arrange
artist = pylast.Artist("Test Artist", self.network)
# Act
representation = repr(artist)
# Assert
assert representation.startswith("pylast.Artist('Test Artist',")
def test_artist_is_hashable(self) -> None:
# Arrange
test_artist = self.network.get_artist("Radiohead")
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:
# Arrange
artist = pylast.Artist("Test Artist", self.network)
# Act
bio = artist.get_bio_published_date()
# Assert
assert bio is not None
assert len(bio) >= 1
def test_bio_content(self) -> None:
# Arrange
artist = pylast.Artist("Test Artist", self.network)
# Act
bio = artist.get_bio_content(language="en")
# Assert
assert bio is not None
assert len(bio) >= 1
def test_bio_content_none(self) -> None:
# Arrange
# An artist with no biography, with "<content/>" in the API XML
artist = pylast.Artist("Mr Sizef + Unquote", self.network)
# Act
bio = artist.get_bio_content()
# Assert
assert bio is None
def test_bio_summary(self) -> None:
# Arrange
artist = pylast.Artist("Test Artist", self.network)
# Act
bio = artist.get_bio_summary(language="en")
# Assert
assert bio is not None
assert len(bio) >= 1
def test_artist_top_tracks(self) -> None:
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
# Act
things = artist.get_top_tracks(limit=2)
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
def test_artist_top_albums(self) -> None:
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
# Act
things = list(artist.get_top_albums(limit=2))
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Album)
@pytest.mark.parametrize("test_limit", [1, 50, 100])
def test_artist_top_albums_limit(self, test_limit: int) -> None:
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
# Act
things = artist.get_top_albums(limit=test_limit)
# Assert
assert len(things) == test_limit
def test_artist_top_albums_limit_default(self) -> None:
# Arrange
# Pick an artist with plenty of plays
artist = self.network.get_top_artists(limit=1)[0].item
# Act
things = artist.get_top_albums()
# Assert
assert len(things) == 50
def test_artist_listener_count(self) -> None:
# Arrange
artist = self.network.get_artist("Test Artist")
# Act
count = artist.get_listener_count()
# Assert
assert isinstance(count, int)
assert count > 0
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
def test_tag_artist(self) -> None:
# Arrange
artist = self.network.get_artist("Test Artist")
# artist.clear_tags()
# Act
artist.add_tag("testing")
# Assert
tags = artist.get_tags()
assert len(tags) > 0
found = any(tag.name == "testing" for tag in tags)
assert found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
def test_remove_tag_of_type_text(self) -> None:
# Arrange
tag = "testing" # text
artist = self.network.get_artist("Test Artist")
artist.add_tag(tag)
# Act
artist.remove_tag(tag)
# Assert
tags = artist.get_tags()
found = any(tag.name == "testing" for tag in tags)
assert not found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
def test_remove_tag_of_type_tag(self) -> None:
# Arrange
tag = pylast.Tag("testing", self.network) # Tag
artist = self.network.get_artist("Test Artist")
artist.add_tag(tag)
# Act
artist.remove_tag(tag)
# Assert
tags = artist.get_tags()
found = any(tag.name == "testing" for tag in tags)
assert not found
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
def test_remove_tags(self) -> None:
# Arrange
tags = ["removetag1", "removetag2"]
artist = self.network.get_artist("Test Artist")
artist.add_tags(tags)
artist.add_tags("1more")
tags_before = artist.get_tags()
# Act
artist.remove_tags(tags)
# Assert
tags_after = artist.get_tags()
assert len(tags_after) == len(tags_before) - 2
found1 = any(tag.name == "removetag1" for tag in tags_after)
found2 = any(tag.name == "removetag2" for tag in tags_after)
assert not found1
assert not found2
@pytest.mark.skipif(not WRITE_TEST, reason="Only test once to avoid collisions")
def test_set_tags(self) -> None:
# Arrange
tags = ["sometag1", "sometag2"]
artist = self.network.get_artist("Test Artist 2")
artist.add_tags(tags)
tags_before = artist.get_tags()
new_tags = ["settag1", "settag2"]
# Act
artist.set_tags(new_tags)
# Assert
tags_after = artist.get_tags()
assert tags_before != tags_after
assert len(tags_after) == 2
found1, found2 = False, False
for tag in tags_after:
if tag.name == "settag1":
found1 = True
elif tag.name == "settag2":
found2 = True
assert found1
assert found2
def test_artists(self) -> None:
# Arrange
artist1 = self.network.get_artist("Radiohead")
artist2 = self.network.get_artist("Portishead")
# Act
url = artist1.get_url()
mbid = artist1.get_mbid()
playcount = artist1.get_playcount()
name = artist1.get_name(properly_capitalized=False)
name_cap = artist1.get_name(properly_capitalized=True)
# Assert
assert playcount > 1
assert artist1 != artist2
assert name.lower() == name_cap.lower()
assert url == "https://www.last.fm/music/radiohead"
assert mbid == "a74b1b7f-71a5-4011-9441-d0b5e4122711"
def test_artist_eq_none_is_false(self) -> None:
# Arrange
artist1 = None
artist2 = pylast.Artist("Test Artist", self.network)
# Act / Assert
assert artist1 != artist2
def test_artist_ne_none_is_true(self) -> None:
# Arrange
artist1 = None
artist2 = pylast.Artist("Test Artist", self.network)
# Act / Assert
assert artist1 != artist2
def test_artist_get_correction(self) -> None:
# Arrange
artist = pylast.Artist("guns and roses", self.network)
# Act
corrected_artist_name = artist.get_correction()
# Assert
assert corrected_artist_name == "Guns N' Roses"
def test_get_userplaycount(self) -> None:
# Arrange
artist = pylast.Artist("John Lennon", self.network, username=self.username)
# Act
playcount = artist.get_userplaycount()
# Assert
assert playcount >= 0

View file

@ -1,36 +0,0 @@
#!/usr/bin/env python
"""
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:
# Arrange
country = self.network.get_country("Italy")
# Act/Assert
self.helper_is_thing_hashable(country)
def test_countries(self) -> None:
# Arrange
country1 = pylast.Country("Italy", self.network)
country2 = pylast.Country("Finland", self.network)
# Act
text = str(country1)
rep = repr(country1)
url = country1.get_url()
# Assert
assert "Italy" in rep
assert "pylast.Country" in rep
assert text == "Italy"
assert country1 == country1
assert country1 != country2
assert url == "https://www.last.fm/place/italy"

View file

@ -1,56 +0,0 @@
#!/usr/bin/env python
"""
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:
# Arrange
library = pylast.Library(user=self.username, network=self.network)
# Act
representation = repr(library)
# Assert
assert representation.startswith("pylast.Library(")
def test_str(self) -> None:
# Arrange
library = pylast.Library(user=self.username, network=self.network)
# Act
string = str(library)
# Assert
assert string.endswith("'s Library")
def test_library_is_hashable(self) -> None:
# Arrange
library = pylast.Library(user=self.username, network=self.network)
# Act/Assert
self.helper_is_thing_hashable(library)
def test_cacheable_library(self) -> None:
# Arrange
library = pylast.Library(self.username, self.network)
# Act/Assert
self.helper_validate_cacheable(library, "get_artists")
def test_get_user(self) -> None:
# Arrange
library = pylast.Library(user=self.username, network=self.network)
user_to_get = self.network.get_user(self.username)
# Act
library_user = library.get_user()
# Assert
assert library_user == user_to_get

View file

@ -1,43 +0,0 @@
#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
from flaky import flaky
import pylast
from .test_pylast import load_secrets
@flaky(max_runs=3, min_passes=1)
class TestPyLastWithLibreFm:
"""Own class for Libre.fm because we don't need the Last.fm setUp"""
def test_libre_fm(self) -> None:
# Arrange
secrets = load_secrets()
username = secrets["username"]
password_hash = secrets["password_hash"]
# Act
network = pylast.LibreFMNetwork(password_hash=password_hash, username=username)
artist = network.get_artist("Radiohead")
name = artist.get_name()
# Assert
assert name == "Radiohead"
def test_repr(self) -> None:
# Arrange
secrets = load_secrets()
username = secrets["username"]
password_hash = secrets["password_hash"]
network = pylast.LibreFMNetwork(password_hash=password_hash, username=username)
# Act
representation = repr(network)
# Assert
assert representation.startswith("pylast.LibreFMNetwork(")

View file

@ -1,420 +0,0 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import re
import time
import pytest
import pylast
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:
# Arrange
artist = "test artist"
title = "test title"
timestamp = self.unix_timestamp()
lastfm_user = self.network.get_user(self.username)
# Act
self.network.scrobble(artist=artist, title="test title 2", timestamp=timestamp)
self.network.scrobble(artist=artist, title=title, timestamp=timestamp)
# Assert
# limit=2 to ignore now-playing:
last_scrobble = list(lastfm_user.get_recent_tracks(limit=2))[0]
assert str(last_scrobble.track.artist).lower() == artist
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:
# Arrange
artist = "Test Artist"
title = "test title"
album = "Test Album"
track_number = 1
lastfm_user = self.network.get_user(self.username)
# Act
self.network.update_now_playing(
artist=artist, title=title, album=album, track_number=track_number
)
# Assert
current_track = lastfm_user.get_now_playing()
assert current_track is not None
assert str(current_track.title).lower() == "test title"
assert str(current_track.artist).lower() == "test artist"
assert current_track.info["album"] == "Test Album"
assert current_track.get_album().title == "Test Album"
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:
# Arrange
assert not self.network.is_rate_limited()
# Act
self.network.enable_rate_limit()
then = time.time()
# Make some network call, limit not applied first time
self.network.get_top_artists()
# Make a second network call, limiting should be applied
self.network.get_top_artists()
now = time.time()
# Assert
assert self.network.is_rate_limited()
assert now - then >= 0.2
def test_disable_rate_limiting(self) -> None:
# Arrange
self.network.enable_rate_limit()
assert self.network.is_rate_limited()
# Act
self.network.disable_rate_limit()
# Make some network call, limit not applied first time
self.network.get_user(self.username)
# Make a second network call, limiting should be applied
self.network.get_top_artists()
# Assert
assert not self.network.is_rate_limited()
def test_lastfm_network_name(self) -> None:
# Act
name = str(self.network)
# Assert
assert name == "Last.fm Network"
def test_geo_get_top_artists(self) -> None:
# Arrange
# Act
artists = self.network.get_geo_top_artists(country="United Kingdom", limit=1)
# Assert
assert len(artists) == 1
assert isinstance(artists[0], pylast.TopItem)
assert isinstance(artists[0].item, pylast.Artist)
def test_geo_get_top_tracks(self) -> None:
# Arrange
# Act
tracks = self.network.get_geo_top_tracks(
country="United Kingdom", location="Manchester", limit=1
)
# Assert
assert len(tracks) == 1
assert isinstance(tracks[0], pylast.TopItem)
assert isinstance(tracks[0].item, pylast.Track)
def test_network_get_top_artists_with_limit(self) -> None:
# Arrange
# Act
artists = self.network.get_top_artists(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
def test_network_get_top_tags_with_limit(self) -> None:
# Arrange
# Act
tags = self.network.get_top_tags(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
def test_network_get_top_tags_with_no_limit(self) -> None:
# Arrange
# Act
tags = self.network.get_top_tags()
# Assert
self.helper_at_least_one_thing_in_top_list(tags, pylast.Tag)
def test_network_get_top_tracks_with_limit(self) -> None:
# Arrange
# Act
tracks = self.network.get_top_tracks(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(tracks, pylast.Track)
def test_country_top_tracks(self) -> None:
# Arrange
country = self.network.get_country("Croatia")
# Act
things = country.get_top_tracks(limit=2)
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
def test_country_network_top_tracks(self) -> None:
# Arrange
# Act
things = self.network.get_geo_top_tracks("Croatia", limit=2)
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
def test_tag_top_tracks(self) -> None:
# Arrange
tag = self.network.get_tag("blues")
# Act
things = tag.get_top_tracks(limit=2)
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
def test_album_data(self) -> None:
# Arrange
thing = self.network.get_album("Test Artist", "Test Album")
# Act
stringed = str(thing)
rep = thing.__repr__()
title = thing.get_title()
name = thing.get_name()
playcount = thing.get_playcount()
url = thing.get_url()
# Assert
assert stringed == "Test Artist - Test Album"
assert "pylast.Album('Test Artist', 'Test Album'," in rep
assert title == name
assert isinstance(playcount, int)
assert playcount > 1
assert "https://www.last.fm/music/test%2bartist/test%2balbum" == url
def test_track_data(self) -> None:
# Arrange
thing = self.network.get_track("Test Artist", "test title")
# Act
stringed = str(thing)
rep = thing.__repr__()
title = thing.get_title()
name = thing.get_name()
playcount = thing.get_playcount()
url = thing.get_url(pylast.DOMAIN_FRENCH)
# Assert
assert stringed == "Test Artist - test title"
assert "pylast.Track('Test Artist', 'test title'," in rep
assert title == "test title"
assert title == name
assert isinstance(playcount, int)
assert playcount > 1
assert "https://www.last.fm/fr/music/test%2bartist/_/test%2btitle" == url
def test_country_top_artists(self) -> None:
# Arrange
country = self.network.get_country("Ukraine")
# Act
artists = country.get_top_artists(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
def test_caching(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
self.network.enable_caching()
tags1 = user.get_top_tags(limit=1, cacheable=True)
tags2 = user.get_top_tags(limit=1, cacheable=True)
# Assert
assert self.network.is_caching_enabled()
assert tags1 == tags2
self.network.disable_caching()
assert not self.network.is_caching_enabled()
def test_album_mbid(self) -> None:
# Arrange
mbid = "03c91c40-49a6-44a7-90e7-a700edf97a62"
# Act
album = self.network.get_album_by_mbid(mbid)
album_mbid = album.get_mbid()
# Assert
assert isinstance(album, pylast.Album)
assert album.title == "Believe"
assert album_mbid == mbid
def test_artist_mbid(self) -> None:
# Arrange
mbid = "7e84f845-ac16-41fe-9ff8-df12eb32af55"
# Act
artist = self.network.get_artist_by_mbid(mbid)
# Assert
assert isinstance(artist, pylast.Artist)
assert artist.name in ("MusicBrainz Test Artist", "MusicBrainzz Test Artist")
def test_track_mbid(self) -> None:
# Arrange
mbid = "ebc037b1-cc9c-44f2-a21f-83c219f0e1e0"
# Act
track = self.network.get_track_by_mbid(mbid)
track_mbid = track.get_mbid()
# Assert
assert isinstance(track, pylast.Track)
assert track.title == "first"
assert track_mbid == mbid
def test_init_with_token(self) -> None:
# Arrange/Act
msg = None
try:
pylast.LastFMNetwork(
api_key=self.__class__.secrets["api_key"],
api_secret=self.__class__.secrets["api_secret"],
token="invalid",
)
except pylast.WSError as exc:
msg = str(exc)
# Assert
assert msg == "Unauthorized Token - This token has not been issued"
def test_proxy(self) -> None:
# Arrange
proxy = "http://example.com:1234"
# Act / Assert
self.network.enable_proxy(proxy)
assert self.network.is_proxy_enabled()
assert self.network.proxy == "http://example.com:1234"
self.network.disable_proxy()
assert not self.network.is_proxy_enabled()
def test_album_search(self) -> None:
# Arrange
album = "Nevermind"
# Act
search = self.network.search_for_album(album)
results = search.get_next_page()
# Assert
assert isinstance(results, list)
assert isinstance(results[0], pylast.Album)
def test_album_search_images(self) -> None:
# Arrange
album = "Nevermind"
search = self.network.search_for_album(album)
# Act
results = search.get_next_page()
images = results[0].info["image"]
# Assert
assert len(images) == 4
assert images[pylast.SIZE_SMALL].startswith("https://")
assert images[pylast.SIZE_SMALL].endswith(".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
def test_artist_search(self) -> None:
# Arrange
artist = "Nirvana"
# Act
search = self.network.search_for_artist(artist)
results = search.get_next_page()
# Assert
assert isinstance(results, list)
assert isinstance(results[0], pylast.Artist)
def test_artist_search_images(self) -> None:
# Arrange
artist = "Nirvana"
search = self.network.search_for_artist(artist)
# Act
results = search.get_next_page()
images = results[0].info["image"]
# Assert
assert len(images) == 5
assert images[pylast.SIZE_SMALL].startswith("https://")
assert images[pylast.SIZE_SMALL].endswith(".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
def test_track_search(self) -> None:
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
# Act
search = self.network.search_for_track(artist, track)
results = search.get_next_page()
# Assert
assert isinstance(results, list)
assert isinstance(results[0], pylast.Track)
def test_track_search_images(self) -> None:
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
search = self.network.search_for_track(artist, track)
# Act
results = search.get_next_page()
images = results[0].info["image"]
# Assert
assert len(images) == 4
assert images[pylast.SIZE_SMALL].startswith("https://")
assert images[pylast.SIZE_SMALL].endswith(".png")
assert "/34s/" in images[pylast.SIZE_SMALL]
assert images[pylast.SIZE_EXTRA_LARGE].startswith("https://")
assert images[pylast.SIZE_EXTRA_LARGE].endswith(".png")
assert "/300x300/" in images[pylast.SIZE_EXTRA_LARGE]
def test_search_get_total_result_count(self) -> None:
# Arrange
artist = "Nirvana"
track = "Smells Like Teen Spirit"
search = self.network.search_for_track(artist, track)
# Act
total = search.get_total_result_count()
# Assert
assert int(total) > 10000

View file

@ -1,138 +0,0 @@
#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import os
import time
import pytest
from flaky import flaky
import pylast
WRITE_TEST = False
def load_secrets(): # pragma: no cover
secrets_file = "test_pylast.yaml"
if os.path.isfile(secrets_file):
import yaml # pip install pyyaml
with open(secrets_file) as f: # see example_test_pylast.yaml
doc = yaml.load(f)
else:
doc = {}
try:
doc["username"] = os.environ["PYLAST_USERNAME"].strip()
doc["password_hash"] = os.environ["PYLAST_PASSWORD_HASH"].strip()
doc["api_key"] = os.environ["PYLAST_API_KEY"].strip()
doc["api_secret"] = os.environ["PYLAST_API_SECRET"].strip()
except KeyError:
pytest.skip("Missing environment variables: PYLAST_USERNAME etc.")
return doc
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:
secrets = None
@staticmethod
def unix_timestamp() -> int:
return int(time.time())
@classmethod
def setup_class(cls) -> None:
if cls.secrets is None:
cls.secrets = load_secrets()
cls.username = cls.secrets["username"]
password_hash = cls.secrets["password_hash"]
api_key = cls.secrets["api_key"]
api_secret = cls.secrets["api_secret"]
cls.network = pylast.LastFMNetwork(
api_key=api_key,
api_secret=api_secret,
username=cls.username,
password_hash=password_hash,
)
@staticmethod
def helper_is_thing_hashable(thing) -> None:
# Arrange
things = set()
# Act
things.add(thing)
# Assert
assert thing is not None
assert len(things) == 1
@staticmethod
def helper_validate_results(a, b, c) -> None:
# Assert
assert a is not None
assert b is not None
assert c is not None
assert isinstance(len(a), int)
assert isinstance(len(b), int)
assert isinstance(len(c), int)
assert a == b
assert b == c
def helper_validate_cacheable(self, thing, function_name) -> None:
# Arrange
# get thing.function_name()
func = getattr(thing, function_name, None)
# Act
result1 = func(limit=1, cacheable=False)
result2 = func(limit=1, cacheable=True)
result3 = list(func(limit=1))
# Assert
self.helper_validate_results(result1, result2, result3)
@staticmethod
def helper_at_least_one_thing_in_top_list(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:
# 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:
# 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:
# Assert
assert len(things) == 2
thing1 = things[0]
thing2 = things[1]
assert isinstance(thing1, pylast.TopItem)
assert isinstance(thing2, pylast.TopItem)
assert isinstance(thing1.item, expected_type)
assert isinstance(thing2.item, expected_type)
assert thing1 != thing2

View file

@ -1,58 +0,0 @@
#!/usr/bin/env python
"""
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:
# 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:
# Arrange
tag = self.network.get_tag("blues")
# Act
artists = tag.get_top_artists(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
def test_tag_top_albums(self) -> None:
# Arrange
tag = self.network.get_tag("blues")
# Act
albums = tag.get_top_albums(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(albums, pylast.Album)
def test_tags(self) -> None:
# Arrange
tag1 = self.network.get_tag("blues")
tag2 = self.network.get_tag("rock")
# Act
tag_repr = repr(tag1)
tag_str = str(tag1)
name = tag1.get_name(properly_capitalized=True)
url = tag1.get_url()
# Assert
assert "blues" == tag_str
assert "pylast.Tag" in tag_repr
assert "blues" in tag_repr
assert "blues" == name
assert tag1 == tag1
assert tag1 != tag2
assert url == "https://www.last.fm/tag/blues"

View file

@ -1,231 +0,0 @@
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import time
import pytest
import pylast
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:
# Arrange
artist = "Test Artist"
title = "test title"
track = self.network.get_track(artist, title)
lastfm_user = self.network.get_user(self.username)
# Act
track.love()
# Assert
loved = list(lastfm_user.get_loved_tracks(limit=1))
assert str(loved[0].track.artist).lower() == "test artist"
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:
# Arrange
artist = pylast.Artist("Test Artist", self.network)
title = "test title"
track = pylast.Track(artist, title, self.network)
lastfm_user = self.network.get_user(self.username)
track.love()
# Act
track.unlove()
time.sleep(1) # Delay, for Last.fm latency. TODO Can this be removed later?
# Assert
loved = list(lastfm_user.get_loved_tracks(limit=1))
if len(loved): # OK to be empty but if not:
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:
# Arrange
artist = "Test Artist"
title = "test title"
track = pylast.Track(
artist=artist, title=title, network=self.network, username=self.username
)
# Act
count = track.get_userplaycount()
# Assert
assert count >= 0
def test_user_loved_in_track_info(self) -> None:
# Arrange
artist = "Test Artist"
title = "test title"
track = pylast.Track(
artist=artist, title=title, network=self.network, username=self.username
)
# Act
loved = track.get_userloved()
# Assert
assert loved is not None
assert isinstance(loved, bool)
assert not isinstance(loved, str)
def test_track_is_hashable(self) -> None:
# Arrange
artist = self.network.get_artist("Test Artist")
track = artist.get_top_tracks(stream=False)[0].item
assert isinstance(track, pylast.Track)
# Act/Assert
self.helper_is_thing_hashable(track)
def test_track_wiki_content(self) -> None:
# Arrange
track = pylast.Track("Test Artist", "test title", self.network)
# Act
wiki = track.get_wiki_content()
# Assert
assert wiki is not None
assert len(wiki) >= 1
def test_track_wiki_summary(self) -> None:
# Arrange
track = pylast.Track("Test Artist", "test title", self.network)
# Act
wiki = track.get_wiki_summary()
# Assert
assert wiki is not None
assert len(wiki) >= 1
def test_track_get_duration(self) -> None:
# Arrange
track = pylast.Track("Daft Punk", "Something About Us", self.network)
# Act
duration = track.get_duration()
# Assert
assert duration >= 100000
def test_track_get_album(self) -> None:
# Arrange
track = pylast.Track("Nirvana", "Lithium", self.network)
# Act
album = track.get_album()
# Assert
assert str(album) == "Nirvana - Nevermind"
def test_track_get_similar(self) -> None:
# Arrange
track = pylast.Track("Cher", "Believe", self.network)
# Act
similar = track.get_similar()
# Assert
found = any(str(track.item) == "Cher - Strong Enough" for track in similar)
assert found
def test_track_get_similar_limits(self) -> None:
# Arrange
track = pylast.Track("Cher", "Believe", self.network)
# Act/Assert
assert len(track.get_similar(limit=20)) == 20
assert len(track.get_similar(limit=10)) <= 10
assert len(track.get_similar(limit=None)) >= 23
assert len(track.get_similar(limit=0)) >= 23
def test_tracks_notequal(self) -> None:
# Arrange
track1 = pylast.Track("Test Artist", "test title", self.network)
track2 = pylast.Track("Test Artist", "Test Track", self.network)
# Act
# Assert
assert track1 != track2
def test_track_title_prop_caps(self) -> None:
# Arrange
track = pylast.Track("test artist", "test title", self.network)
# Act
title = track.get_title(properly_capitalized=True)
# Assert
assert title == "Test Title"
def test_track_listener_count(self) -> None:
# Arrange
track = pylast.Track("test artist", "test title", self.network)
# Act
count = track.get_listener_count()
# Assert
assert count > 21
def test_album_tracks(self) -> None:
# Arrange
album = pylast.Album("Test Artist", "Test", self.network)
# Act
tracks = album.get_tracks()
url = tracks[0].get_url()
# Assert
assert isinstance(tracks, list)
assert isinstance(tracks[0], pylast.Track)
assert len(tracks) == 1
assert url.startswith("https://www.last.fm/music/test")
def test_track_eq_none_is_false(self) -> None:
# Arrange
track1 = None
track2 = pylast.Track("Test Artist", "test title", self.network)
# Act / Assert
assert track1 != track2
def test_track_ne_none_is_true(self) -> None:
# Arrange
track1 = None
track2 = pylast.Track("Test Artist", "test title", self.network)
# Act / Assert
assert track1 != track2
def test_track_get_correction(self) -> None:
# Arrange
track = pylast.Track("Guns N' Roses", "mrbrownstone", self.network)
# Act
corrected_track_name = track.get_correction()
# Assert
assert corrected_track_name == "Mr. Brownstone"
def test_track_with_no_mbid(self) -> None:
# Arrange
track = pylast.Track("Static-X", "Set It Off", self.network)
# Act
mbid = track.get_mbid()
# Assert
assert mbid is None

View file

@ -1,496 +0,0 @@
#!/usr/bin/env python
"""
Integration (not unit) tests for pylast.py
"""
from __future__ import annotations
import calendar
import datetime as dt
import inspect
import os
import re
import pytest
import pylast
from .test_pylast import TestPyLastWithLastFm
class TestPyLastUser(TestPyLastWithLastFm):
def test_repr(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
representation = repr(user)
# Assert
assert representation.startswith("pylast.User('RJ',")
def test_str(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
string = str(user)
# Assert
assert string == "RJ"
def test_equality(self) -> None:
# Arrange
user_1a = self.network.get_user("RJ")
user_1b = self.network.get_user("RJ")
user_2 = self.network.get_user("Test User")
not_a_user = self.network
# Act / Assert
assert user_1a == user_1b
assert user_1a != user_2
assert user_1a != not_a_user
def test_get_name(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
name = user.get_name(properly_capitalized=True)
# Assert
assert name == "RJ"
def test_get_user_registration(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
registered = user.get_registered()
# Assert
if int(registered):
# Last.fm API broken? Used to be yyyy-mm-dd not Unix timestamp
assert registered == "1037793040"
else: # pragma: no cover
# Old way
# Just check date because of timezones
assert "2002-11-20 " in registered
def test_get_user_unixtime_registration(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
unixtime_registered = user.get_unixtime_registered()
# Assert
# Just check date because of timezones
assert unixtime_registered == 1037793040
def test_get_countryless_user(self) -> None:
# Arrange
# Currently test_user has no country set:
lastfm_user = self.network.get_user("test_user")
# Act
country = lastfm_user.get_country()
# Assert
assert country is None
def test_user_get_country(self) -> None:
# Arrange
lastfm_user = self.network.get_user("RJ")
# Act
country = lastfm_user.get_country()
# Assert
assert str(country) == "United Kingdom"
def test_user_equals_none(self) -> None:
# Arrange
lastfm_user = self.network.get_user(self.username)
# Act
value = lastfm_user is None
# Assert
assert not value
def test_user_not_equal_to_none(self) -> None:
# Arrange
lastfm_user = self.network.get_user(self.username)
# Act
value = lastfm_user is not None
# Assert
assert value
def test_now_playing_user_with_no_scrobbles(self) -> None:
# Arrange
# Currently test-account has no scrobbles:
user = self.network.get_user("test-account")
# Act
current_track = user.get_now_playing()
# Assert
assert current_track is None
def test_love_limits(self) -> None:
# Arrange
# Currently test-account has at least 23 loved tracks:
user = self.network.get_user("test-user")
# Act/Assert
assert len(user.get_loved_tracks(limit=20)) == 20
assert len(user.get_loved_tracks(limit=100)) <= 100
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:
# Arrange
user = self.network.get_user(self.username)
# Act/Assert
self.helper_is_thing_hashable(user)
# Commented out because (a) it'll take a long time and (b) it strangely
# fails due Last.fm's complaining of hitting the rate limit, even when
# limited to one call per second. The ToS allows 5 calls per second.
# def test_get_all_scrobbles(self):
# # Arrange
# lastfm_user = self.network.get_user("RJ")
# self.network.enable_rate_limit() # this is going to be slow...
#
# # Act
# tracks = lastfm_user.get_recent_tracks(limit=None)
#
# # Assert
# self.assertGreaterEqual(len(tracks), 0)
def test_pickle(self) -> None:
# Arrange
import pickle
lastfm_user = self.network.get_user(self.username)
filename = str(self.unix_timestamp()) + ".pkl"
# Act
with open(filename, "wb") as f:
pickle.dump(lastfm_user, f)
with open(filename, "rb") as f:
loaded_user = pickle.load(f)
os.remove(filename)
# Assert
assert lastfm_user == loaded_user
@pytest.mark.xfail
def test_cacheable_user(self) -> None:
# Arrange
lastfm_user = self.network.get_authenticated_user()
# Act/Assert
self.helper_validate_cacheable(lastfm_user, "get_friends")
# no cover whilst xfail:
self.helper_validate_cacheable( # pragma: no cover
lastfm_user, "get_loved_tracks"
)
self.helper_validate_cacheable( # pragma: no cover
lastfm_user, "get_recent_tracks"
)
def test_user_get_top_tags_with_limit(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
tags = user.get_top_tags(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(tags, pylast.Tag)
def test_user_top_tracks(self) -> None:
# Arrange
lastfm_user = self.network.get_user("RJ")
# Act
things = lastfm_user.get_top_tracks(limit=2)
# Assert
self.helper_two_different_things_in_top_list(things, pylast.Track)
def helper_assert_chart(self, chart, expected_type) -> None:
# 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:
# Arrange
album_chart, track_chart = None, None
(from_date, to_date) = date
# Act
artist_chart = thing.get_weekly_artist_charts(from_date, to_date)
if type(thing) is not pylast.Tag:
album_chart = thing.get_weekly_album_charts(from_date, to_date)
track_chart = thing.get_weekly_track_charts(from_date, to_date)
# Assert
self.helper_assert_chart(artist_chart, pylast.Artist)
if type(thing) is not pylast.Tag:
self.helper_assert_chart(album_chart, pylast.Album)
self.helper_assert_chart(track_chart, pylast.Track)
def helper_dates_valid(self, dates) -> None:
# Assert
assert len(dates) >= 1
assert isinstance(dates[0], tuple)
(start, end) = dates[0]
assert start < end
def test_user_charts(self) -> None:
# Arrange
lastfm_user = self.network.get_user("RJ")
dates = lastfm_user.get_weekly_chart_dates()
self.helper_dates_valid(dates)
# Act/Assert
self.helper_get_assert_charts(lastfm_user, dates[0])
def test_user_top_artists(self) -> None:
# Arrange
lastfm_user = self.network.get_user(self.username)
# Act
artists = lastfm_user.get_top_artists(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(artists, pylast.Artist)
def test_user_top_albums(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
albums = user.get_top_albums(limit=1)
# Assert
self.helper_only_one_thing_in_top_list(albums, pylast.Album)
top_album = albums[0].item
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:
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["artisttagola"]
artist = self.network.get_artist("Test Artist")
artist.add_tags(tags)
# Act
artists = lastfm_user.get_tagged_artists("artisttagola", limit=1)
# Assert
self.helper_only_one_thing_in_list(artists, pylast.Artist)
def test_user_tagged_albums(self) -> None:
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["albumtagola"]
album = self.network.get_album("Test Artist", "Test Album")
album.add_tags(tags)
# Act
albums = lastfm_user.get_tagged_albums("albumtagola", limit=1)
# Assert
self.helper_only_one_thing_in_list(albums, pylast.Album)
def test_user_tagged_tracks(self) -> None:
# Arrange
lastfm_user = self.network.get_user(self.username)
tags = ["tracktagola"]
track = self.network.get_track("Test Artist", "test title")
track.add_tags(tags)
# Act
tracks = lastfm_user.get_tagged_tracks("tracktagola", limit=1)
# Assert
self.helper_only_one_thing_in_list(tracks, pylast.Track)
def test_user_subscriber(self) -> None:
# Arrange
subscriber = self.network.get_user("RJ")
non_subscriber = self.network.get_user("Test User")
# Act
subscriber_is_subscriber = subscriber.is_subscriber()
non_subscriber_is_subscriber = non_subscriber.is_subscriber()
# Assert
assert subscriber_is_subscriber
assert not non_subscriber_is_subscriber
def test_user_get_image(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
url = user.get_image()
# Assert
assert url.startswith("https://")
def test_user_get_library(self) -> None:
# Arrange
user = self.network.get_user(self.username)
# Act
library = user.get_library()
# Assert
assert isinstance(library, pylast.Library)
def test_get_recent_tracks_from_to(self) -> None:
# Arrange
lastfm_user = self.network.get_user("RJ")
start = dt.datetime(2011, 7, 21, 15, 10)
end = dt.datetime(2011, 7, 21, 15, 15)
utc_start = calendar.timegm(start.utctimetuple())
utc_end = calendar.timegm(end.utctimetuple())
# Act
tracks = lastfm_user.get_recent_tracks(time_from=utc_start, time_to=utc_end)
# Assert
assert len(tracks) == 1
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:
# Arrange
lastfm_user = self.network.get_user("bbc6music")
start = dt.datetime(2020, 2, 15, 15, 00)
end = dt.datetime(2020, 2, 15, 15, 40)
utc_start = calendar.timegm(start.utctimetuple())
utc_end = calendar.timegm(end.utctimetuple())
# Act
tracks = lastfm_user.get_recent_tracks(
time_from=utc_start, time_to=utc_end, limit=None
)
# Assert
assert len(tracks) == 11
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:
# Arrange
lastfm_user = self.network.get_user("bbc6music")
start = dt.datetime(2020, 2, 15, 15, 00)
end = dt.datetime(2020, 2, 15, 15, 40)
utc_start = calendar.timegm(start.utctimetuple())
utc_end = calendar.timegm(end.utctimetuple())
# Act
tracks = lastfm_user.get_recent_tracks(
time_from=utc_start, time_to=utc_end, limit=None, stream=True
)
# Assert
assert inspect.isgenerator(tracks)
def test_get_playcount(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
playcount = user.get_playcount()
# Assert
assert playcount >= 128387
def test_get_image(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
image = user.get_image()
# Assert
assert image.startswith("https://")
assert image.endswith(".png")
def test_get_url(self) -> None:
# Arrange
user = self.network.get_user("RJ")
# Act
url = user.get_url()
# Assert
assert url == "https://www.last.fm/user/rj"
def test_get_weekly_artist_charts(self) -> None:
# Arrange
user = self.network.get_user("bbc6music")
# Act
charts = user.get_weekly_artist_charts()
artist, weight = charts[0]
# Assert
assert artist is not None
assert isinstance(artist.network, pylast.LastFMNetwork)
def test_get_weekly_track_charts(self) -> None:
# Arrange
user = self.network.get_user("bbc6music")
# Act
charts = user.get_weekly_track_charts()
track, weight = charts[0]
# Assert
assert track is not None
assert isinstance(track.network, pylast.LastFMNetwork)
def test_user_get_track_scrobbles(self) -> None:
# Arrange
artist = "France Gall"
title = "Laisse Tomber Les Filles"
user = self.network.get_user("bbc6music")
# Act
scrobbles = user.get_track_scrobbles(artist, title)
# Assert
assert len(scrobbles) > 0
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:
# Arrange
artist = "France Gall"
title = "Laisse Tomber Les Filles"
user = self.network.get_user("bbc6music")
# Act
result1 = user.get_track_scrobbles(artist, title, cacheable=False)
result2 = list(user.get_track_scrobbles(artist, title, cacheable=True))
result3 = list(user.get_track_scrobbles(artist, title))
# Assert
self.helper_validate_results(result1, result2, result3)

View file

@ -1,70 +0,0 @@
from __future__ import annotations
from unittest import mock
import pytest
import pylast
def mock_network():
return mock.Mock(_get_ws_auth=mock.Mock(return_value=("", "", "")))
@pytest.mark.parametrize(
"artist",
[
"\xe9lafdasfdsafdsa",
"ééééééé",
pylast.Artist("B\xe9l", mock_network()),
"fdasfdsafsaf not unicode",
],
)
def test_get_cache_key(artist) -> None:
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)
assert isinstance(hash(obj), int)
@pytest.mark.parametrize(
"test_input, expected",
[
(
# Plain text
'<album mbid="">test album name</album>',
'<album mbid="">test album name</album>',
),
(
# Contains Unicode ENQ Enquiry control character
'<album mbid="">test album \u0005name</album>',
'<album mbid="">test album name</album>',
),
],
)
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
'<album mbid="">test album name</album>',
'<?xml version="1.0" ?><album mbid="">test album name</album>',
),
(
# Contains Unicode ENQ Enquiry control character
'<album mbid="">test album \u0005name</album>',
'<?xml version="1.0" ?><album mbid="">test album name</album>',
),
],
)
def test__parse_response(test_input: str, expected: str) -> None:
doc = pylast._parse_response(test_input)
assert doc.toxml() == expected

40
tox.ini
View file

@ -1,40 +0,0 @@
[tox]
requires =
tox>=4.2
env_list =
lint
py{py3, 313, 312, 311, 310, 39, 38}
[testenv]
extras =
tests
pass_env =
FORCE_COLOR
PYLAST_API_KEY
PYLAST_API_SECRET
PYLAST_PASSWORD_HASH
PYLAST_USERNAME
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
[testenv:venv]
deps =
ipdb
commands =
{posargs}