lexicon-python 0.1.0__tar.gz → 0.1.2__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.
- {lexicon_python-0.1.0/src/lexicon_python.egg-info → lexicon_python-0.1.2}/PKG-INFO +1 -1
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/pyproject.toml +1 -1
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon/lexicon.py +99 -24
- {lexicon_python-0.1.0 → lexicon_python-0.1.2/src/lexicon_python.egg-info}/PKG-INFO +1 -1
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/tests/test_lexicon.py +53 -28
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/LICENSE +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/README.md +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/setup.cfg +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon/__init__.py +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/SOURCES.txt +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/requires.txt +0 -0
- {lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/top_level.txt +0 -0
|
@@ -134,18 +134,24 @@ class LexiconClient:
|
|
|
134
134
|
def get_playlist_by_path(
|
|
135
135
|
self,
|
|
136
136
|
playlist_path: Sequence[str],
|
|
137
|
-
playlist_type:
|
|
137
|
+
playlist_type: int,
|
|
138
138
|
*,
|
|
139
139
|
timeout: Optional[int] = None,
|
|
140
140
|
) -> dict | None:
|
|
141
141
|
"""
|
|
142
142
|
Get a playlist from the Lexicon library by its folder path.
|
|
143
143
|
Via ``/v1/playlist-by-path`` endpoint.
|
|
144
|
+
|
|
145
|
+
playlist_path:
|
|
146
|
+
Sequence of folder/playlist names from the root.
|
|
147
|
+
E.g. ``["Genres", "Drum & Bass", "Dancefloor"]``.
|
|
148
|
+
|
|
149
|
+
playlist_type:
|
|
150
|
+
``1=Folder``, ``2=Playlist``, ``3=Smartlist``.
|
|
144
151
|
"""
|
|
145
152
|
endpoint = self._build_url("/v1/playlist-by-path")
|
|
146
153
|
params: list[tuple[str, object]] = [("path", part) for part in playlist_path]
|
|
147
|
-
|
|
148
|
-
params.append(("type", playlist_type))
|
|
154
|
+
params.append(("type", playlist_type))
|
|
149
155
|
|
|
150
156
|
try:
|
|
151
157
|
response = requests.get(
|
|
@@ -179,7 +185,7 @@ class LexiconClient:
|
|
|
179
185
|
folder: dict,
|
|
180
186
|
input_func: Callable[[str], str] = input,
|
|
181
187
|
show_counts: bool = False,
|
|
182
|
-
) -> Optional[
|
|
188
|
+
) -> Optional[dict]:
|
|
183
189
|
"""Interactively choose an item within ``folder``.
|
|
184
190
|
|
|
185
191
|
``0`` backs out (or cancels at the root). ``S`` selects the current folder.
|
|
@@ -256,14 +262,17 @@ class LexiconClient:
|
|
|
256
262
|
choice = input_func("\nSelect number (Enter: current folder, C: cancel)").strip()
|
|
257
263
|
|
|
258
264
|
# Handle special inputs
|
|
265
|
+
# Enter: select current folder
|
|
259
266
|
if not choice:
|
|
260
267
|
playlist = self.get_playlist(current_folder.get("id")) # Fetch full details
|
|
261
|
-
return playlist
|
|
268
|
+
return playlist
|
|
262
269
|
|
|
270
|
+
# C/c: cancel
|
|
263
271
|
if choice.lower() == "c":
|
|
264
272
|
print("Selection cancelled.")
|
|
265
273
|
return None
|
|
266
274
|
|
|
275
|
+
# 0: back out
|
|
267
276
|
if choice == "0":
|
|
268
277
|
if len(stack) > 1:
|
|
269
278
|
stack.pop()
|
|
@@ -297,7 +306,7 @@ class LexiconClient:
|
|
|
297
306
|
# Playlist/Smartlist
|
|
298
307
|
if selected_type in {2, 3}:
|
|
299
308
|
playlist = self.get_playlist(selected.get("id")) # Fetch full details
|
|
300
|
-
return playlist
|
|
309
|
+
return playlist
|
|
301
310
|
|
|
302
311
|
# Folder
|
|
303
312
|
elif selected_type == 1:
|
|
@@ -356,10 +365,10 @@ class LexiconClient:
|
|
|
356
365
|
show_counts: bool = True,
|
|
357
366
|
timeout: Optional[int] = None,
|
|
358
367
|
input_func: Callable[[str], str] = input,
|
|
359
|
-
) -> Optional[
|
|
368
|
+
) -> Optional[dict]:
|
|
360
369
|
"""Fetch playlists and interactively choose one via stdin.
|
|
361
370
|
|
|
362
|
-
Returns a
|
|
371
|
+
Returns a playlist dict or ``None`` if the user cancels.
|
|
363
372
|
|
|
364
373
|
Parameters
|
|
365
374
|
----------
|
|
@@ -391,6 +400,53 @@ class LexiconClient:
|
|
|
391
400
|
self._logger.info("No playlist selected.")
|
|
392
401
|
return selection
|
|
393
402
|
|
|
403
|
+
def get_playlist_path(self, playlist_input: int | dict, *, timeout: Optional[int] = None) -> Optional[list[str]]:
|
|
404
|
+
"""
|
|
405
|
+
Fetch the full folder path for a playlist grabbing parent playlists.
|
|
406
|
+
Input is either a playlist ID or a playlist dictionary.
|
|
407
|
+
Returns a list of folder/playlist names from root to the target playlist.
|
|
408
|
+
"""
|
|
409
|
+
path = []
|
|
410
|
+
playlist_parent = None
|
|
411
|
+
current_playlist = None
|
|
412
|
+
current_name = None
|
|
413
|
+
|
|
414
|
+
if isinstance(playlist_input, dict):
|
|
415
|
+
current_playlist = playlist_input
|
|
416
|
+
elif isinstance(playlist_input, int):
|
|
417
|
+
playlist_id = playlist_input
|
|
418
|
+
current_playlist = self.get_playlist(playlist_id)
|
|
419
|
+
|
|
420
|
+
if not current_playlist:
|
|
421
|
+
self._logger.warning("Invalid playlist input; must be ID or playlist dict.")
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
while True:
|
|
425
|
+
parent_id = current_playlist.get("parentId")
|
|
426
|
+
if parent_id is None:
|
|
427
|
+
# Reached root, stop
|
|
428
|
+
break
|
|
429
|
+
|
|
430
|
+
# Prepend current name to path
|
|
431
|
+
current_name = current_playlist.get("name", "(unnamed)")
|
|
432
|
+
if isinstance(current_name, str):
|
|
433
|
+
path.insert(0, current_name)
|
|
434
|
+
else:
|
|
435
|
+
self._logger.warning("Playlist has invalid name: %s", current_name)
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# Fetch parent playlist and continue
|
|
439
|
+
if isinstance(parent_id, int):
|
|
440
|
+
current_playlist = self.get_playlist(parent_id)
|
|
441
|
+
if not current_playlist:
|
|
442
|
+
self._logger.warning("Could not fetch parent playlist with ID %s", parent_id)
|
|
443
|
+
return None
|
|
444
|
+
else:
|
|
445
|
+
self._logger.warning("Playlist has invalid parentId: %s", parent_id)
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
return path
|
|
449
|
+
|
|
394
450
|
#endregion
|
|
395
451
|
|
|
396
452
|
# ------------------------------------------------------------------
|
|
@@ -477,7 +533,6 @@ class LexiconClient:
|
|
|
477
533
|
while total_remaining is None or (total_remaining > 0 and get_all):
|
|
478
534
|
page_params = list(params)
|
|
479
535
|
page_params.append(("offset", next_offset))
|
|
480
|
-
print(page_params) # DEBUG
|
|
481
536
|
|
|
482
537
|
try:
|
|
483
538
|
response = requests.get(
|
|
@@ -612,25 +667,32 @@ class LexiconClient:
|
|
|
612
667
|
track_ids: Iterable[int],
|
|
613
668
|
*,
|
|
614
669
|
max_workers: int = 5,
|
|
670
|
+
show_progress: bool = True,
|
|
615
671
|
timeout: Optional[int] = None,
|
|
616
|
-
) -> list[dict]:
|
|
617
|
-
"""
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
672
|
+
) -> list[dict] | None:
|
|
673
|
+
"""Fetch metadata for a collection of tracks, optionally in parallel.
|
|
674
|
+
|
|
675
|
+
Defaults to making 5 requests in parallel. Set ``max_workers=0`` to fetch
|
|
676
|
+
one at a time. More than 5 workers doesn't seem to improve speed but
|
|
677
|
+
results may vary. Progress bars can be disabled with
|
|
678
|
+
``show_progress=False``.
|
|
623
679
|
"""
|
|
680
|
+
|
|
624
681
|
track_ids = list(track_ids)
|
|
625
|
-
results: list[dict] = []
|
|
626
682
|
|
|
627
|
-
if not track_ids:
|
|
628
|
-
|
|
683
|
+
if any(not isinstance(tid, int) for tid in track_ids):
|
|
684
|
+
self._logger.warning("track_ids must be an iterable of integers.")
|
|
685
|
+
return None
|
|
629
686
|
|
|
630
|
-
|
|
687
|
+
results: list[dict] = []
|
|
631
688
|
|
|
689
|
+
effective_timeout = timeout or self.default_timeout
|
|
690
|
+
|
|
632
691
|
if max_workers == 0:
|
|
633
|
-
|
|
692
|
+
iterable = track_ids
|
|
693
|
+
if show_progress:
|
|
694
|
+
iterable = tqdm(track_ids, desc="Fetching tracks", unit=" tracks")
|
|
695
|
+
for track_id in iterable:
|
|
634
696
|
info = self.get_track(track_id, timeout=effective_timeout)
|
|
635
697
|
if info:
|
|
636
698
|
results.append(info)
|
|
@@ -641,8 +703,15 @@ class LexiconClient:
|
|
|
641
703
|
executor.submit(self.get_track, track_id, timeout=effective_timeout): track_id
|
|
642
704
|
for track_id in track_ids
|
|
643
705
|
}
|
|
644
|
-
|
|
645
|
-
|
|
706
|
+
|
|
707
|
+
completed = as_completed(futures)
|
|
708
|
+
if show_progress:
|
|
709
|
+
progress = tqdm(total=len(futures), desc="Fetching tracks (parallel)", unit=" tracks")
|
|
710
|
+
else:
|
|
711
|
+
progress = None
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
for future in completed:
|
|
646
715
|
track_id = futures[future]
|
|
647
716
|
try:
|
|
648
717
|
info = future.result()
|
|
@@ -651,7 +720,11 @@ class LexiconClient:
|
|
|
651
720
|
except Exception as exc: # noqa: BLE001 - handle worker failures gracefully
|
|
652
721
|
self._logger.warning("Track %s failed during fetch: %s", track_id, exc)
|
|
653
722
|
finally:
|
|
654
|
-
|
|
723
|
+
if progress:
|
|
724
|
+
progress.update(1)
|
|
725
|
+
finally:
|
|
726
|
+
if progress:
|
|
727
|
+
progress.close()
|
|
655
728
|
|
|
656
729
|
return results
|
|
657
730
|
|
|
@@ -684,6 +757,8 @@ class LexiconClient:
|
|
|
684
757
|
self._logger.warning("Response did not contain expected tags structure")
|
|
685
758
|
return None
|
|
686
759
|
|
|
760
|
+
#endregion
|
|
761
|
+
|
|
687
762
|
__all__ = [
|
|
688
763
|
"DEFAULT_HOST",
|
|
689
764
|
"LEXICON_PORT",
|
|
@@ -13,7 +13,6 @@ if str(SRC_DIR) not in sys.path:
|
|
|
13
13
|
|
|
14
14
|
from lexicon import LexiconClient # noqa: E402 pylint: disable=wrong-import-position
|
|
15
15
|
|
|
16
|
-
|
|
17
16
|
class DummyResponse:
|
|
18
17
|
def __init__(self, payload):
|
|
19
18
|
self._payload = payload
|
|
@@ -51,16 +50,17 @@ class LexiconApiTests(unittest.TestCase):
|
|
|
51
50
|
"id": 1,
|
|
52
51
|
"name": "ROOT",
|
|
53
52
|
"type": "1",
|
|
53
|
+
"parentId": None,
|
|
54
54
|
"playlists": [
|
|
55
|
-
{"id": 2, "name": "Folder 1", "type": "1", "playlists": [ # 1.
|
|
56
|
-
{"id": 5, "name": "Playlist 1", "type": "2"}, # 1.
|
|
57
|
-
{"id": 6, "name": "Smartlist 1", "type": "3"} # 2.
|
|
55
|
+
{"id": 2, "name": "Folder 1", "type": "1", "parentId": 1, "playlists": [ # 1.
|
|
56
|
+
{"id": 5, "name": "Playlist 1", "type": "2", "parentId": 2}, # 1.
|
|
57
|
+
{"id": 6, "name": "Smartlist 1", "type": "3", "parentId": 2} # 2.
|
|
58
58
|
]},
|
|
59
|
-
{"id": 3, "name": "Playlist 2", "type": "2"}, # 2.
|
|
60
|
-
{"id": 4, "name": "Folder 2", "type": "1", "playlists": [ # 3.
|
|
61
|
-
{"id": 7, "name": "Playlist 3", "type": "2"}, # 1.
|
|
62
|
-
{"id": 8, "name": "Folder 3", "type": "1", "playlists": [ # 2.
|
|
63
|
-
{"id": 9, "name": "Playlist 4", "type": "2"} # 1.
|
|
59
|
+
{"id": 3, "name": "Playlist 2", "type": "2", "parentId": 1}, # 2.
|
|
60
|
+
{"id": 4, "name": "Folder 2", "type": "1", "parentId": 1, "playlists": [ # 3.
|
|
61
|
+
{"id": 7, "name": "Playlist 3", "type": "2", "parentId": 4}, # 1.
|
|
62
|
+
{"id": 8, "name": "Folder 3", "type": "1", "parentId": 4, "playlists": [ # 2.
|
|
63
|
+
{"id": 9, "name": "Playlist 4", "type": "2", "parentId": 8} # 1.
|
|
64
64
|
]}
|
|
65
65
|
]},
|
|
66
66
|
],
|
|
@@ -149,27 +149,29 @@ class LexiconApiTests(unittest.TestCase):
|
|
|
149
149
|
return DummyErrorResponse(error_payload)
|
|
150
150
|
|
|
151
151
|
with patch("lexicon.lexicon.requests.get", fake_get):
|
|
152
|
-
playlist = self.client.get_playlist_by_path(playlist_path)
|
|
152
|
+
playlist = self.client.get_playlist_by_path(playlist_path, playlist_type=2)
|
|
153
153
|
|
|
154
154
|
self.assertIsNone(playlist)
|
|
155
155
|
|
|
156
156
|
def test_lexicon_tree_to_flat_list_builds_paths(self, tree=tree):
|
|
157
157
|
flattened = self.client._flatten_tree(tree)
|
|
158
|
+
|
|
158
159
|
self.assertEqual(
|
|
159
160
|
flattened,
|
|
160
161
|
{
|
|
161
162
|
"id": 1,
|
|
162
163
|
"name": "ROOT",
|
|
163
164
|
"type": "1",
|
|
165
|
+
"parentId": None,
|
|
164
166
|
"playlists": [
|
|
165
|
-
{"id": 2, "name": "Folder 1", "type": "2", "path": ["Folder 1"]},
|
|
166
|
-
{"id": 5, "name": "Playlist 1", "type": "2", "path": ["Folder 1", "Playlist 1"]},
|
|
167
|
-
{"id": 6, "name": "Smartlist 1", "type": "3", "path": ["Folder 1", "Smartlist 1"]},
|
|
168
|
-
{"id": 3, "name": "Playlist 2", "type": "2", "path": ["Playlist 2"]},
|
|
169
|
-
{"id": 4, "name": "Folder 2", "type": "2", "path": ["Folder 2"]},
|
|
170
|
-
{"id": 7, "name": "Playlist 3", "type": "2", "path": ["Folder 2", "Playlist 3"]},
|
|
171
|
-
{"id": 8, "name": "Folder 3", "type": "2", "path": ["Folder 2", "Folder 3"]},
|
|
172
|
-
{"id": 9, "name": "Playlist 4", "type": "2", "path": ["Folder 2", "Folder 3", "Playlist 4"]},
|
|
167
|
+
{"id": 2, "name": "Folder 1", "type": "2", "parentId": 1, "path": ["Folder 1"]},
|
|
168
|
+
{"id": 5, "name": "Playlist 1", "type": "2", "parentId": 2, "path": ["Folder 1", "Playlist 1"]},
|
|
169
|
+
{"id": 6, "name": "Smartlist 1", "type": "3", "parentId": 2, "path": ["Folder 1", "Smartlist 1"]},
|
|
170
|
+
{"id": 3, "name": "Playlist 2", "type": "2", "parentId": 1, "path": ["Playlist 2"]},
|
|
171
|
+
{"id": 4, "name": "Folder 2", "type": "2", "parentId": 1, "path": ["Folder 2"]},
|
|
172
|
+
{"id": 7, "name": "Playlist 3", "type": "2", "parentId": 4, "path": ["Folder 2", "Playlist 3"]},
|
|
173
|
+
{"id": 8, "name": "Folder 3", "type": "2", "parentId": 4, "path": ["Folder 2", "Folder 3"]},
|
|
174
|
+
{"id": 9, "name": "Playlist 4", "type": "2", "parentId": 8, "path": ["Folder 2", "Folder 3", "Playlist 4"]},
|
|
173
175
|
]
|
|
174
176
|
}
|
|
175
177
|
)
|
|
@@ -179,39 +181,62 @@ class LexiconApiTests(unittest.TestCase):
|
|
|
179
181
|
|
|
180
182
|
with patch.object(self.client, "get_playlists", return_value=tree), \
|
|
181
183
|
patch.object(self.client, "get_playlist", side_effect=self.fake_playlist):
|
|
182
|
-
playlist
|
|
184
|
+
playlist = self.client.choose_playlist(flat=False, show_counts=False, input_func=lambda _: next(inputs))
|
|
183
185
|
|
|
184
186
|
self.assertIsNotNone(playlist)
|
|
185
|
-
self.assertEqual(
|
|
187
|
+
self.assertEqual(playlist, {"id": 7, "trackIds": [1, 2, 3]})
|
|
186
188
|
|
|
187
189
|
def test_choose_playlist_handles_folder_selection(self, tree=tree):
|
|
188
190
|
inputs = iter(["1", ""]) # Navigate to Folder 1 -> Select Folder 1
|
|
189
191
|
|
|
190
192
|
with patch.object(self.client, "get_playlists", return_value=tree), \
|
|
191
193
|
patch.object(self.client, "get_playlist", side_effect=self.fake_playlist):
|
|
192
|
-
|
|
194
|
+
playlist = self.client.choose_playlist(flat=False, show_counts=False, input_func=lambda _: next(inputs))
|
|
193
195
|
|
|
194
|
-
self.assertIsNotNone(
|
|
195
|
-
self.assertEqual(
|
|
196
|
+
self.assertIsNotNone(playlist)
|
|
197
|
+
self.assertEqual(playlist, {"id": 2, "trackIds": [1, 2, 3]})
|
|
196
198
|
|
|
197
199
|
def test_choose_playlist_flat_selection(self, tree=tree):
|
|
198
200
|
inputs = iter(["7"]) # Select Playlist 1 directly from flat list
|
|
199
201
|
|
|
200
202
|
with patch.object(self.client, "get_playlists", return_value=tree), \
|
|
201
203
|
patch.object(self.client, "get_playlist", side_effect=self.fake_playlist):
|
|
202
|
-
|
|
204
|
+
playlist = self.client.choose_playlist(flat=True, show_counts=False, input_func=lambda _: next(inputs))
|
|
203
205
|
|
|
204
|
-
self.assertIsNotNone(
|
|
205
|
-
self.assertEqual(
|
|
206
|
+
self.assertIsNotNone(playlist)
|
|
207
|
+
self.assertEqual(playlist, {"id": 8, "trackIds": [1, 2, 3]})
|
|
206
208
|
|
|
207
209
|
def test_choose_playlist_handles_cancel(self, tree=tree):
|
|
208
|
-
inputs = iter(["3", "c"]) # Select Folder 2 then
|
|
210
|
+
inputs = iter(["3", "c"]) # Select Folder 2 then cancel
|
|
209
211
|
|
|
210
212
|
with patch.object(self.client, "get_playlists", return_value=tree):
|
|
211
213
|
result = self.client.choose_playlist(show_counts=False, input_func=lambda _: next(inputs))
|
|
212
|
-
|
|
214
|
+
|
|
213
215
|
self.assertIsNone(result)
|
|
214
216
|
|
|
217
|
+
def test_get_playlist_path_returns_correct_path(self, tree=tree):
|
|
218
|
+
id_index: dict[int, dict] = {}
|
|
219
|
+
|
|
220
|
+
def index_tree(node: dict):
|
|
221
|
+
id_index[node["id"]] = {k: v for k, v in node.items() if k != "playlists"}
|
|
222
|
+
for child in node.get("playlists") or []:
|
|
223
|
+
index_tree(child)
|
|
224
|
+
|
|
225
|
+
index_tree(tree)
|
|
226
|
+
|
|
227
|
+
with patch.object(self.client, "get_playlist", side_effect=lambda playlist_id: id_index.get(playlist_id)):
|
|
228
|
+
path = self.client.get_playlist_path(7)
|
|
229
|
+
|
|
230
|
+
self.assertEqual(path, ["Folder 2", "Playlist 3"])
|
|
231
|
+
|
|
232
|
+
def test_get_playlist_path_handles_missing_parent(self):
|
|
233
|
+
orphan = {"id": 42, "name": "Orphan", "parentId": 999}
|
|
234
|
+
|
|
235
|
+
with patch.object(self.client, "get_playlist", return_value=None):
|
|
236
|
+
path = self.client.get_playlist_path(orphan)
|
|
237
|
+
|
|
238
|
+
self.assertIsNone(path)
|
|
239
|
+
|
|
215
240
|
def test_get_track_info_returns_full_track(self):
|
|
216
241
|
track_payload = {
|
|
217
242
|
"id": 123,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lexicon_python-0.1.0 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|