tunewrangler 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tunewrangler-1.0.0/LICENSE +21 -0
- tunewrangler-1.0.0/PKG-INFO +148 -0
- tunewrangler-1.0.0/README.md +133 -0
- tunewrangler-1.0.0/pyproject.toml +44 -0
- tunewrangler-1.0.0/setup.cfg +8 -0
- tunewrangler-1.0.0/tests/test_cli.py +172 -0
- tunewrangler-1.0.0/tests/test_last_fm_client.py +75 -0
- tunewrangler-1.0.0/tests/test_notification_launcher.py +65 -0
- tunewrangler-1.0.0/tests/test_spotify_client.py +413 -0
- tunewrangler-1.0.0/tests/test_tidal_additional.py +361 -0
- tunewrangler-1.0.0/tests/test_tidal_client.py +1640 -0
- tunewrangler-1.0.0/tunewrangler/__init__.py +32 -0
- tunewrangler-1.0.0/tunewrangler/__main__.py +4 -0
- tunewrangler-1.0.0/tunewrangler/cli.py +415 -0
- tunewrangler-1.0.0/tunewrangler/errors.py +22 -0
- tunewrangler-1.0.0/tunewrangler/last_fm_client.py +68 -0
- tunewrangler-1.0.0/tunewrangler/spotify_client.py +518 -0
- tunewrangler-1.0.0/tunewrangler/tidal_api.py +1443 -0
- tunewrangler-1.0.0/tunewrangler/tidal_client.py +480 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/PKG-INFO +148 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/SOURCES.txt +24 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/dependency_links.txt +1 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/entry_points.txt +2 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/requires.txt +6 -0
- tunewrangler-1.0.0/tunewrangler.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexandre Chabot-Leclerc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tunewrangler
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Author: Alexandre Chabot-Leclerc
|
|
5
|
+
Requires-Python: <3.13,>=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: spotipy
|
|
10
|
+
Requires-Dist: notify-py
|
|
11
|
+
Requires-Dist: platformdirs
|
|
12
|
+
Requires-Dist: pylast
|
|
13
|
+
Requires-Dist: keyring
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
## Install requirements
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uv sync --group dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For notifications, the script uses [Notificator](https://github.com/vitorgalvao/notificator). It's in the same repo as this project.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Add your service credentials in the Login keychain under the names:
|
|
26
|
+
|
|
27
|
+
- tunewrangler-spotify-client-id
|
|
28
|
+
- tunewrangler-spotify-client-secret
|
|
29
|
+
- tunewrangler-last-fm-api-key
|
|
30
|
+
- tunewrangler-last-fm-password
|
|
31
|
+
- tunewrangler-last-fm-shared-secret
|
|
32
|
+
- tunewrangler-tidal-client-id
|
|
33
|
+
- tunewrangler-tidal-redirect-uri
|
|
34
|
+
|
|
35
|
+
TIDAL also supports environment overrides:
|
|
36
|
+
|
|
37
|
+
- `TUNEWRANGLER_TIDAL_CLIENT_ID`
|
|
38
|
+
- `TUNEWRANGLER_TIDAL_REDIRECT_URI`
|
|
39
|
+
- `TUNEWRANGLER_TIDAL_SCOPES`
|
|
40
|
+
|
|
41
|
+
The default TIDAL OAuth scopes are:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
search.read collection.read collection.write playlists.read playlists.write
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
TIDAL folder access uses separate folder-capable session data. TuneWrangler
|
|
48
|
+
automatically prompts for TIDAL device login when a folder command needs it and
|
|
49
|
+
stores that folder session inside `tidal-session.json`.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
Save current track in Spotify and "Love" the track on LastFM.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
uv run tunewrangler save track
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Favorite the current track by saving it to your library and this year's
|
|
60
|
+
playlist.
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
uv run tunewrangler favorite track
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Favorite the current album by saving it to your library and this year's albums
|
|
67
|
+
playlist.
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
uv run tunewrangler favorite album
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Create a playlist from the currently playing album.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
uv run tunewrangler collect album
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Show CLI help
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
uv run tunewrangler --help
|
|
84
|
+
uv run python -m tunewrangler --help
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run tests and style tools:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
uv run pytest
|
|
91
|
+
uv run pytest --cov
|
|
92
|
+
uv run black .
|
|
93
|
+
uv run pycodestyle
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The coverage run reports branch and line coverage for `tunewrangler`, including
|
|
97
|
+
the line numbers that are not covered.
|
|
98
|
+
|
|
99
|
+
## Publishing to PyPI
|
|
100
|
+
|
|
101
|
+
The [publish workflow](.github/workflows/publish.yml) builds and checks a wheel
|
|
102
|
+
and source distribution when a GitHub release is published, then uploads them
|
|
103
|
+
using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
|
|
104
|
+
No PyPI API token or GitHub secret is needed.
|
|
105
|
+
|
|
106
|
+
Before the first release:
|
|
107
|
+
|
|
108
|
+
1. Create a GitHub Actions environment named `pypi` in this repository's
|
|
109
|
+
**Settings → Environments**.
|
|
110
|
+
2. Configure a GitHub trusted publisher on PyPI with these values:
|
|
111
|
+
|
|
112
|
+
| Field | Value |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| PyPI project name | `tunewrangler` |
|
|
115
|
+
| Owner | `achabotl` |
|
|
116
|
+
| Repository | `tunewrangler` |
|
|
117
|
+
| Workflow filename | `publish.yml` |
|
|
118
|
+
| Environment | `pypi` |
|
|
119
|
+
|
|
120
|
+
For a new project, use [PyPI's pending publisher form](https://pypi.org/manage/account/publishing/).
|
|
121
|
+
For an existing project you own, add the publisher under **Manage → Publishing**.
|
|
122
|
+
|
|
123
|
+
For each release:
|
|
124
|
+
|
|
125
|
+
1. Set a new, unused `version` in `pyproject.toml` and run `uv lock` to update
|
|
126
|
+
the lockfile. Run `uv run pytest` and commit the changes.
|
|
127
|
+
2. Merge the publishing workflow and version changes into `main`.
|
|
128
|
+
3. Create and publish a GitHub release targeting that commit with a matching
|
|
129
|
+
tag, such as `v1.0.0` for version `1.0.0`.
|
|
130
|
+
4. Check that the **Publish to PyPI** workflow succeeds in GitHub Actions.
|
|
131
|
+
|
|
132
|
+
Draft releases do not publish. Publishing a release (including a prerelease)
|
|
133
|
+
uploads to the real PyPI index. The version comes from `pyproject.toml`; the
|
|
134
|
+
workflow does not derive it from the tag. PyPI does not allow reusing a published
|
|
135
|
+
version.
|
|
136
|
+
|
|
137
|
+
## Documentation of Dependencies
|
|
138
|
+
|
|
139
|
+
### Spotify
|
|
140
|
+
|
|
141
|
+
- [Spotipy!](https://spotipy.readthedocs.io/) and its [examples](https://github.com/plamere/spotipy/tree/master/examples)
|
|
142
|
+
- [Spotify API docs](https://developer.spotify.com/documentation/web-api/reference/)
|
|
143
|
+
|
|
144
|
+
### Last.FM
|
|
145
|
+
|
|
146
|
+
- [Last.fm API docs](https://www.last.fm/api/rest)
|
|
147
|
+
- Python package: [pylast · PyPI](https://pypi.org/project/pylast/)
|
|
148
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
## Install requirements
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
uv sync --group dev
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
For notifications, the script uses [Notificator](https://github.com/vitorgalvao/notificator). It's in the same repo as this project.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Add your service credentials in the Login keychain under the names:
|
|
11
|
+
|
|
12
|
+
- tunewrangler-spotify-client-id
|
|
13
|
+
- tunewrangler-spotify-client-secret
|
|
14
|
+
- tunewrangler-last-fm-api-key
|
|
15
|
+
- tunewrangler-last-fm-password
|
|
16
|
+
- tunewrangler-last-fm-shared-secret
|
|
17
|
+
- tunewrangler-tidal-client-id
|
|
18
|
+
- tunewrangler-tidal-redirect-uri
|
|
19
|
+
|
|
20
|
+
TIDAL also supports environment overrides:
|
|
21
|
+
|
|
22
|
+
- `TUNEWRANGLER_TIDAL_CLIENT_ID`
|
|
23
|
+
- `TUNEWRANGLER_TIDAL_REDIRECT_URI`
|
|
24
|
+
- `TUNEWRANGLER_TIDAL_SCOPES`
|
|
25
|
+
|
|
26
|
+
The default TIDAL OAuth scopes are:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
search.read collection.read collection.write playlists.read playlists.write
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
TIDAL folder access uses separate folder-capable session data. TuneWrangler
|
|
33
|
+
automatically prompts for TIDAL device login when a folder command needs it and
|
|
34
|
+
stores that folder session inside `tidal-session.json`.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Save current track in Spotify and "Love" the track on LastFM.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
uv run tunewrangler save track
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Favorite the current track by saving it to your library and this year's
|
|
45
|
+
playlist.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
uv run tunewrangler favorite track
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Favorite the current album by saving it to your library and this year's albums
|
|
52
|
+
playlist.
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
uv run tunewrangler favorite album
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Create a playlist from the currently playing album.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
uv run tunewrangler collect album
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
Show CLI help
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
uv run tunewrangler --help
|
|
69
|
+
uv run python -m tunewrangler --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run tests and style tools:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
uv run pytest
|
|
76
|
+
uv run pytest --cov
|
|
77
|
+
uv run black .
|
|
78
|
+
uv run pycodestyle
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The coverage run reports branch and line coverage for `tunewrangler`, including
|
|
82
|
+
the line numbers that are not covered.
|
|
83
|
+
|
|
84
|
+
## Publishing to PyPI
|
|
85
|
+
|
|
86
|
+
The [publish workflow](.github/workflows/publish.yml) builds and checks a wheel
|
|
87
|
+
and source distribution when a GitHub release is published, then uploads them
|
|
88
|
+
using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
|
|
89
|
+
No PyPI API token or GitHub secret is needed.
|
|
90
|
+
|
|
91
|
+
Before the first release:
|
|
92
|
+
|
|
93
|
+
1. Create a GitHub Actions environment named `pypi` in this repository's
|
|
94
|
+
**Settings → Environments**.
|
|
95
|
+
2. Configure a GitHub trusted publisher on PyPI with these values:
|
|
96
|
+
|
|
97
|
+
| Field | Value |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| PyPI project name | `tunewrangler` |
|
|
100
|
+
| Owner | `achabotl` |
|
|
101
|
+
| Repository | `tunewrangler` |
|
|
102
|
+
| Workflow filename | `publish.yml` |
|
|
103
|
+
| Environment | `pypi` |
|
|
104
|
+
|
|
105
|
+
For a new project, use [PyPI's pending publisher form](https://pypi.org/manage/account/publishing/).
|
|
106
|
+
For an existing project you own, add the publisher under **Manage → Publishing**.
|
|
107
|
+
|
|
108
|
+
For each release:
|
|
109
|
+
|
|
110
|
+
1. Set a new, unused `version` in `pyproject.toml` and run `uv lock` to update
|
|
111
|
+
the lockfile. Run `uv run pytest` and commit the changes.
|
|
112
|
+
2. Merge the publishing workflow and version changes into `main`.
|
|
113
|
+
3. Create and publish a GitHub release targeting that commit with a matching
|
|
114
|
+
tag, such as `v1.0.0` for version `1.0.0`.
|
|
115
|
+
4. Check that the **Publish to PyPI** workflow succeeds in GitHub Actions.
|
|
116
|
+
|
|
117
|
+
Draft releases do not publish. Publishing a release (including a prerelease)
|
|
118
|
+
uploads to the real PyPI index. The version comes from `pyproject.toml`; the
|
|
119
|
+
workflow does not derive it from the tag. PyPI does not allow reusing a published
|
|
120
|
+
version.
|
|
121
|
+
|
|
122
|
+
## Documentation of Dependencies
|
|
123
|
+
|
|
124
|
+
### Spotify
|
|
125
|
+
|
|
126
|
+
- [Spotipy!](https://spotipy.readthedocs.io/) and its [examples](https://github.com/plamere/spotipy/tree/master/examples)
|
|
127
|
+
- [Spotify API docs](https://developer.spotify.com/documentation/web-api/reference/)
|
|
128
|
+
|
|
129
|
+
### Last.FM
|
|
130
|
+
|
|
131
|
+
- [Last.fm API docs](https://www.last.fm/api/rest)
|
|
132
|
+
- Python package: [pylast · PyPI](https://pypi.org/project/pylast/)
|
|
133
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tunewrangler"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
authors = [{name = "Alexandre Chabot-Leclerc"}]
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.13"
|
|
7
|
+
dependencies=[
|
|
8
|
+
"click",
|
|
9
|
+
"spotipy",
|
|
10
|
+
"notify-py",
|
|
11
|
+
"platformdirs",
|
|
12
|
+
"pylast",
|
|
13
|
+
"keyring",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = [
|
|
18
|
+
"black",
|
|
19
|
+
"pycodestyle",
|
|
20
|
+
"pytest>=7.0",
|
|
21
|
+
"pytest-cov>=7.0",
|
|
22
|
+
"pytest-mock>=3.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.coverage.run]
|
|
29
|
+
branch = true
|
|
30
|
+
source = ["tunewrangler"]
|
|
31
|
+
|
|
32
|
+
[tool.coverage.report]
|
|
33
|
+
show_missing = true
|
|
34
|
+
fail_under = 85
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["tunewrangler*"]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["setuptools"]
|
|
41
|
+
build-backend = "setuptools.build_meta"
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
tunewrangler = "tunewrangler.cli:cli"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from unittest.mock import Mock, call, patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from tunewrangler.cli import Client, cli, notify
|
|
7
|
+
from tunewrangler.errors import NotPlayingError, PlaylistExistsError, TrackNotFoundError
|
|
8
|
+
from tunewrangler.tidal_api import TidalRateLimitError, TidalUnsupportedFeatureError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_notify_returns_when_helper_is_missing():
|
|
12
|
+
with (
|
|
13
|
+
patch("tunewrangler.cli.shutil.which", return_value=None),
|
|
14
|
+
patch("tunewrangler.cli.subprocess.run") as run,
|
|
15
|
+
):
|
|
16
|
+
notify("Title", "Message", "Subtitle")
|
|
17
|
+
run.assert_not_called()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_notify_invokes_helper_with_all_arguments():
|
|
21
|
+
with (
|
|
22
|
+
patch("tunewrangler.cli.shutil.which", return_value="helper"),
|
|
23
|
+
patch("tunewrangler.cli.subprocess.run") as run,
|
|
24
|
+
):
|
|
25
|
+
notify("Title", "Message", "Subtitle")
|
|
26
|
+
command = run.call_args.args[0]
|
|
27
|
+
assert "TuneWranglerNotificator.app" in str(command[0])
|
|
28
|
+
assert command[1:] == [
|
|
29
|
+
"--title",
|
|
30
|
+
"Title",
|
|
31
|
+
"--subtitle",
|
|
32
|
+
"Subtitle",
|
|
33
|
+
"--message",
|
|
34
|
+
"Message",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_client_sound_workflows_delegate():
|
|
39
|
+
music = Mock()
|
|
40
|
+
lastfm = Mock()
|
|
41
|
+
music.save_current_track.return_value = ("Artist", "Track", "track-1")
|
|
42
|
+
music.save_current_album.return_value = ("Artist", "Album")
|
|
43
|
+
client = Client(music, lastfm)
|
|
44
|
+
|
|
45
|
+
client.save_current_track()
|
|
46
|
+
client.save_current_album()
|
|
47
|
+
client.favorite_current_track()
|
|
48
|
+
client.favorite_current_album()
|
|
49
|
+
|
|
50
|
+
music.save_track_to_weekly_playlist.assert_called_once_with("track-1")
|
|
51
|
+
lastfm.save_track.assert_called_once_with("Artist", "Track")
|
|
52
|
+
music.save_current_album.assert_called_once_with()
|
|
53
|
+
music.favorite_current_track.assert_called_once_with()
|
|
54
|
+
music.favorite_current_album.assert_called_once_with()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize(
|
|
58
|
+
("error", "title"),
|
|
59
|
+
[
|
|
60
|
+
(NotPlayingError(), "Nothing Playing"),
|
|
61
|
+
(PlaylistExistsError(), "Album already exists"),
|
|
62
|
+
(TrackNotFoundError(), "Track Not Found"),
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
def test_collect_album_maps_known_errors_to_notifications(error, title):
|
|
66
|
+
music = Mock()
|
|
67
|
+
music.save_album_of_current_track_in_new_playlist.side_effect = error
|
|
68
|
+
with patch("tunewrangler.cli.notify") as notify_mock:
|
|
69
|
+
Client(music, None).save_album_of_current_track_in_new_playlist()
|
|
70
|
+
assert any(title in item.args[0] for item in notify_mock.call_args_list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def invoke_with_clients(arguments, music=None, lastfm=None):
|
|
74
|
+
music = music or Mock()
|
|
75
|
+
lastfm = lastfm or Mock()
|
|
76
|
+
with (
|
|
77
|
+
patch("tunewrangler.cli.TidalClient", return_value=music),
|
|
78
|
+
patch("tunewrangler.cli.SpotifyClient", return_value=music),
|
|
79
|
+
patch("tunewrangler.cli.LastFmClient", return_value=lastfm),
|
|
80
|
+
):
|
|
81
|
+
result = CliRunner().invoke(cli, arguments, obj={})
|
|
82
|
+
return result, music, lastfm
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_spotify_service_selection_and_multi_playlist_output():
|
|
86
|
+
music = Mock()
|
|
87
|
+
music.list_playlist_names.return_value = ["Alpha", "Beta"]
|
|
88
|
+
result, _, _ = invoke_with_clients(
|
|
89
|
+
["--service", "spotify", "list", "playlists"], music=music
|
|
90
|
+
)
|
|
91
|
+
assert result.exit_code == 0
|
|
92
|
+
assert result.output == "Alpha\nBeta\n"
|
|
93
|
+
music.authenticate.assert_called_once_with()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
("arguments", "method", "output"),
|
|
98
|
+
[
|
|
99
|
+
(["url", "track"], "get_url_of_current_track", "https://track"),
|
|
100
|
+
(["url", "album"], "get_url_of_current_album", "https://album"),
|
|
101
|
+
(["url", "weekly"], "get_weekly_playlist_url", "https://weekly"),
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
def test_url_commands(arguments, method, output):
|
|
105
|
+
music = Mock()
|
|
106
|
+
getattr(music, method).return_value = output
|
|
107
|
+
result, _, _ = invoke_with_clients(arguments, music=music)
|
|
108
|
+
assert result.exit_code == 0
|
|
109
|
+
assert result.output.strip() == output
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_refresh_tidal_unsupported_is_clean_click_error():
|
|
113
|
+
music = Mock()
|
|
114
|
+
music.update_playlist_cache.side_effect = TidalUnsupportedFeatureError(
|
|
115
|
+
"TIDAL playlist cache refresh is not supported."
|
|
116
|
+
)
|
|
117
|
+
result, _, _ = invoke_with_clients(["refresh", "playlists"], music=music)
|
|
118
|
+
assert result.exit_code == 1
|
|
119
|
+
assert "Error: TIDAL playlist cache refresh is not supported." in result.output
|
|
120
|
+
assert "Traceback" not in result.output
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.parametrize(
|
|
124
|
+
"error",
|
|
125
|
+
[TidalRateLimitError("slow down"), TrackNotFoundError("not found")],
|
|
126
|
+
)
|
|
127
|
+
def test_sound_commands_map_domain_errors(error):
|
|
128
|
+
music = Mock()
|
|
129
|
+
music.authenticate.side_effect = error
|
|
130
|
+
result, _, _ = invoke_with_clients(["save", "album"], music=music)
|
|
131
|
+
assert result.exit_code == 1
|
|
132
|
+
assert str(error) in result.output
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize(
|
|
136
|
+
("service", "answers", "expected_calls", "success"),
|
|
137
|
+
[
|
|
138
|
+
(
|
|
139
|
+
"spotify",
|
|
140
|
+
["client", "secret"],
|
|
141
|
+
2,
|
|
142
|
+
"Spotify keys set successfully.",
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
"lastfm",
|
|
146
|
+
["key", "secret", "password"],
|
|
147
|
+
3,
|
|
148
|
+
"LastFM keys set successfully.",
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
"tidal",
|
|
152
|
+
["client", "http://callback"],
|
|
153
|
+
2,
|
|
154
|
+
"TIDAL keys set successfully.",
|
|
155
|
+
),
|
|
156
|
+
],
|
|
157
|
+
)
|
|
158
|
+
def test_auth_set_for_every_service(service, answers, expected_calls, success):
|
|
159
|
+
with (
|
|
160
|
+
patch("tunewrangler.cli.getpass", side_effect=answers),
|
|
161
|
+
patch("tunewrangler.cli.keyring.set_password") as set_password,
|
|
162
|
+
):
|
|
163
|
+
result = CliRunner().invoke(cli, ["auth", "set", "--service", service], obj={})
|
|
164
|
+
assert result.exit_code == 0
|
|
165
|
+
assert success in result.output
|
|
166
|
+
assert set_password.call_count == expected_calls
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_auth_set_requires_service():
|
|
170
|
+
result = CliRunner().invoke(cli, ["auth", "set"], obj={})
|
|
171
|
+
assert result.exit_code == 0
|
|
172
|
+
assert "Please specify a service" in result.output
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from tunewrangler.errors import AuthenticationError
|
|
7
|
+
from tunewrangler.last_fm_client import LastFmClient, missing_key_message
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_missing_key_message_points_to_auth_command():
|
|
11
|
+
message = missing_key_message("example-key")
|
|
12
|
+
assert "example-key" in message
|
|
13
|
+
assert "tunewrangler auth set" in message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize(
|
|
17
|
+
("values", "missing_name"),
|
|
18
|
+
[
|
|
19
|
+
([None], "api-key"),
|
|
20
|
+
(["key", None], "shared-secret"),
|
|
21
|
+
(["key", "secret", None], "password"),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
@patch("tunewrangler.last_fm_client.keyring.get_password")
|
|
25
|
+
def test_authenticate_reports_each_missing_credential(
|
|
26
|
+
get_password, values, missing_name
|
|
27
|
+
):
|
|
28
|
+
get_password.side_effect = values
|
|
29
|
+
|
|
30
|
+
with pytest.raises(AuthenticationError, match=missing_name):
|
|
31
|
+
LastFmClient().authenticate()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@patch("tunewrangler.last_fm_client.pylast.LastFMNetwork")
|
|
35
|
+
@patch("tunewrangler.last_fm_client.pylast.md5", return_value="password-hash")
|
|
36
|
+
@patch("tunewrangler.last_fm_client.keyring.get_password")
|
|
37
|
+
def test_authenticate_builds_network(get_password, md5, network):
|
|
38
|
+
get_password.side_effect = ["api-key", "shared-secret", "password"]
|
|
39
|
+
client = LastFmClient()
|
|
40
|
+
|
|
41
|
+
client.authenticate()
|
|
42
|
+
|
|
43
|
+
md5.assert_called_once_with("password")
|
|
44
|
+
network.assert_called_once_with(
|
|
45
|
+
api_key="api-key",
|
|
46
|
+
api_secret="shared-secret",
|
|
47
|
+
username=client.username,
|
|
48
|
+
password_hash="password-hash",
|
|
49
|
+
)
|
|
50
|
+
assert client.lastfm is network.return_value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@patch(
|
|
54
|
+
"tunewrangler.last_fm_client.keyring.get_password",
|
|
55
|
+
side_effect=RuntimeError("keychain failed"),
|
|
56
|
+
)
|
|
57
|
+
def test_authenticate_maps_unexpected_failures(_get_password):
|
|
58
|
+
with pytest.raises(AuthenticationError, match="Can't authenticate"):
|
|
59
|
+
LastFmClient().authenticate()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_save_track_looks_up_and_loves_track():
|
|
63
|
+
track = SimpleNamespace(
|
|
64
|
+
artist=SimpleNamespace(name="Artist One"),
|
|
65
|
+
title="Track One",
|
|
66
|
+
love=Mock(),
|
|
67
|
+
)
|
|
68
|
+
client = LastFmClient()
|
|
69
|
+
client.lastfm = Mock()
|
|
70
|
+
client.lastfm.get_track.return_value = track
|
|
71
|
+
|
|
72
|
+
client.save_track("Artist One", "Track One")
|
|
73
|
+
|
|
74
|
+
client.lastfm.get_track.assert_called_once_with("Artist One", "Track One")
|
|
75
|
+
track.love.assert_called_once_with()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Exercise launcher arguments without launching a GUI app or posting notifications."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize("open_status", [0, 73])
|
|
14
|
+
def test_launcher_uses_launchservices_and_preserves_literal_arguments(
|
|
15
|
+
tmp_path, open_status
|
|
16
|
+
):
|
|
17
|
+
app = tmp_path / "App With Spaces.app"
|
|
18
|
+
scripts = app / "Contents/Resources/Scripts"
|
|
19
|
+
scripts.mkdir(parents=True)
|
|
20
|
+
launcher = scripts / "notificator"
|
|
21
|
+
shutil.copyfile(
|
|
22
|
+
Path(__file__).resolve().parents[1] / "notifications/notificator", launcher
|
|
23
|
+
)
|
|
24
|
+
executable = app / "Contents/MacOS/applet"
|
|
25
|
+
executable.parent.mkdir()
|
|
26
|
+
executable.write_text("#!/bin/sh\nexit 99\n")
|
|
27
|
+
executable.chmod(0o755)
|
|
28
|
+
command_dir = tmp_path / "bin"
|
|
29
|
+
command_dir.mkdir()
|
|
30
|
+
capture = tmp_path / "open-arguments.json"
|
|
31
|
+
open_command = command_dir / "open"
|
|
32
|
+
open_command.write_text(
|
|
33
|
+
f"#!{sys.executable}\n"
|
|
34
|
+
"import json, os, pathlib, sys\n"
|
|
35
|
+
"destination = pathlib.Path(os.environ['OPEN_ARGUMENTS'])\n"
|
|
36
|
+
"destination.write_text(json.dumps(sys.argv[1:]))\n"
|
|
37
|
+
"sys.exit(int(os.environ['OPEN_STATUS']))\n"
|
|
38
|
+
)
|
|
39
|
+
open_command.chmod(0o755)
|
|
40
|
+
message = 'Literal $(exit 42) `exit 42` "quoted" text\nsecond line'
|
|
41
|
+
title = "--literal-title"
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["/bin/bash", str(launcher), "--message", message, "--title", title],
|
|
44
|
+
env={
|
|
45
|
+
**os.environ,
|
|
46
|
+
"PATH": str(command_dir) + os.pathsep + os.environ["PATH"],
|
|
47
|
+
"OPEN_ARGUMENTS": str(capture),
|
|
48
|
+
"OPEN_STATUS": str(open_status),
|
|
49
|
+
},
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=10,
|
|
53
|
+
)
|
|
54
|
+
assert result.returncode == open_status, result.stderr
|
|
55
|
+
assert json.loads(capture.read_text()) == [
|
|
56
|
+
"-n",
|
|
57
|
+
"-g",
|
|
58
|
+
"-W",
|
|
59
|
+
str(app),
|
|
60
|
+
"--args",
|
|
61
|
+
message,
|
|
62
|
+
title,
|
|
63
|
+
"",
|
|
64
|
+
"",
|
|
65
|
+
]
|