Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Live API E2E

on:
push:
branches: [master]
paths:
- ".github/workflows/e2e.yml"
- "src/**"
- "tests/e2e/**"
- "pyproject.toml"
- "uv.lock"
schedule:
- cron: "17 9 * * 1"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
COVERPY_RUN_E2E: "1"
UV_VERSION: "0.11.32"

jobs:
live-api:
name: Apple live APIs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
- run: uv python install 3.14
- run: uv sync --locked --all-groups --python 3.14
- run: uv run --python 3.14 pytest -m e2e tests/e2e --no-cov
11 changes: 10 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,13 @@ uv build --no-sources
uvx --from twine twine check dist/*
```

Tests must mock network access. Do not depend on the live Apple endpoint in the test suite.
The default suite mocks network access so pull requests remain deterministic. Run the
separate end-to-end suite to exercise the public iTunes API, Apple Music motion metadata,
HLS playlists, direct MP4 delivery, and the installed CLI against live services:

```console
COVERPY_RUN_E2E=1 uv run pytest -m e2e tests/e2e --no-cov
```

GitHub Actions runs these tests after relevant pushes to `master`, every Monday, and on
demand. Live failures can indicate an Apple API change rather than a CoverPy regression.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,16 @@ uv build --no-sources
uvx --from twine twine check dist/*
```

Run the opt-in end-to-end tests against Apple's live APIs:

```console
COVERPY_RUN_E2E=1 uv run pytest -m e2e tests/e2e --no-cov
```

The repository uses GitHub Actions instead of Travis CI. CI tests every supported Python
version, checks formatting, linting, typing, coverage, and verifies both wheel and source
distributions.
distributions. A separate scheduled workflow validates the iTunes catalog, CLI, Apple Music
motion metadata, HLS playlists, and direct MP4 delivery against the live services.

## Releasing

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ build-backend = "uv_build"

[tool.pytest.ini_options]
addopts = "--cov=coverpy --cov-report=term-missing --cov-fail-under=95 -ra"
markers = ["e2e: calls Apple's live APIs and requires COVERPY_RUN_E2E=1"]
testpaths = ["tests"]

[tool.coverage.run]
Expand Down
110 changes: 110 additions & 0 deletions tests/e2e/test_live_apis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

import json
import os
import subprocess
import sys
from urllib.parse import urlparse

import pytest
import requests

from coverpy import CoverPy

pytestmark = [
pytest.mark.e2e,
pytest.mark.skipif(
os.environ.get("COVERPY_RUN_E2E") != "1",
reason="set COVERPY_RUN_E2E=1 to call Apple's live APIs",
),
]

OK_COMPUTER_ID = 1097861387
MOTION_ALBUM_ID = 1693323844


def _assert_https_url(url: str | None, *, suffix: str | None = None) -> str:
assert url is not None
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc
if suffix is not None:
assert parsed.path.endswith(suffix)
return url


def test_live_search_lookup_and_static_artwork() -> None:
with CoverPy(country="US", timeout=30) as client:
results = client.search("OK Computer Radiohead", limit=3)
lookup_results = client.lookup(OK_COMPUTER_ID)

matching_album = next(
(result for result in results if result.artist_name == "Radiohead"),
None,
)
assert matching_album is not None
assert matching_album.collection_name
assert matching_album.type == "album"
assert "1200x1200" in matching_album.artwork(1200)
_assert_https_url(matching_album.store_url)

assert lookup_results
looked_up_album = lookup_results[0]
assert looked_up_album.collection_id == OK_COMPUTER_ID
assert looked_up_album.artist_name == "Radiohead"
_assert_https_url(looked_up_album.artwork_url)


def test_live_cli_searches_and_emits_json() -> None:
completed = subprocess.run(
[
sys.executable,
"-m",
"coverpy.cli",
"OK Computer Radiohead",
"--limit",
"1",
"--json",
],
check=False,
capture_output=True,
text=True,
timeout=60,
)

assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert isinstance(payload, list)
assert len(payload) == 1
assert payload[0]["artist"] == "Radiohead"
_assert_https_url(payload[0]["artwork_url"])


def test_live_motion_artwork_resolves_playable_video() -> None:
with CoverPy(country="US", timeout=30) as client:
artwork = client.get_motion_artwork(MOTION_ALBUM_ID)

assert artwork is not None
assert artwork.album_id == MOTION_ALBUM_ID
hls_url = _assert_https_url(artwork.hls_url, suffix=".m3u8")
_assert_https_url(artwork.tall_hls_url, suffix=".m3u8")
video_urls = {
_assert_https_url(artwork.video_url, suffix=".mp4"),
_assert_https_url(artwork.hq_video_url, suffix=".mp4"),
}

hls_response = requests.get(hls_url, timeout=30)
hls_response.raise_for_status()
assert hls_response.text.startswith("#EXTM3U")

for video_url in video_urls:
with requests.get(
video_url,
headers={"Range": "bytes=0-0"},
stream=True,
timeout=30,
) as response:
response.raise_for_status()
assert response.status_code in {200, 206}
assert response.headers.get("Content-Type", "").startswith("video/mp4")
assert next(response.iter_content(chunk_size=1))
Loading