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