lexicon-python 0.1.1__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.1/src/lexicon_python.egg-info → lexicon_python-0.1.2}/PKG-INFO +1 -1
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/pyproject.toml +1 -1
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon/lexicon.py +90 -21
- {lexicon_python-0.1.1 → lexicon_python-0.1.2/src/lexicon_python.egg-info}/PKG-INFO +1 -1
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/tests/test_lexicon.py +52 -27
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/LICENSE +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/README.md +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/setup.cfg +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon/__init__.py +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/SOURCES.txt +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/dependency_links.txt +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/requires.txt +0 -0
- {lexicon_python-0.1.1 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/top_level.txt +0 -0
|
@@ -185,7 +185,7 @@ class LexiconClient:
|
|
|
185
185
|
folder: dict,
|
|
186
186
|
input_func: Callable[[str], str] = input,
|
|
187
187
|
show_counts: bool = False,
|
|
188
|
-
) -> Optional[
|
|
188
|
+
) -> Optional[dict]:
|
|
189
189
|
"""Interactively choose an item within ``folder``.
|
|
190
190
|
|
|
191
191
|
``0`` backs out (or cancels at the root). ``S`` selects the current folder.
|
|
@@ -262,14 +262,17 @@ class LexiconClient:
|
|
|
262
262
|
choice = input_func("\nSelect number (Enter: current folder, C: cancel)").strip()
|
|
263
263
|
|
|
264
264
|
# Handle special inputs
|
|
265
|
+
# Enter: select current folder
|
|
265
266
|
if not choice:
|
|
266
267
|
playlist = self.get_playlist(current_folder.get("id")) # Fetch full details
|
|
267
|
-
return playlist
|
|
268
|
+
return playlist
|
|
268
269
|
|
|
270
|
+
# C/c: cancel
|
|
269
271
|
if choice.lower() == "c":
|
|
270
272
|
print("Selection cancelled.")
|
|
271
273
|
return None
|
|
272
274
|
|
|
275
|
+
# 0: back out
|
|
273
276
|
if choice == "0":
|
|
274
277
|
if len(stack) > 1:
|
|
275
278
|
stack.pop()
|
|
@@ -303,7 +306,7 @@ class LexiconClient:
|
|
|
303
306
|
# Playlist/Smartlist
|
|
304
307
|
if selected_type in {2, 3}:
|
|
305
308
|
playlist = self.get_playlist(selected.get("id")) # Fetch full details
|
|
306
|
-
return playlist
|
|
309
|
+
return playlist
|
|
307
310
|
|
|
308
311
|
# Folder
|
|
309
312
|
elif selected_type == 1:
|
|
@@ -362,10 +365,10 @@ class LexiconClient:
|
|
|
362
365
|
show_counts: bool = True,
|
|
363
366
|
timeout: Optional[int] = None,
|
|
364
367
|
input_func: Callable[[str], str] = input,
|
|
365
|
-
) -> Optional[
|
|
368
|
+
) -> Optional[dict]:
|
|
366
369
|
"""Fetch playlists and interactively choose one via stdin.
|
|
367
370
|
|
|
368
|
-
Returns a
|
|
371
|
+
Returns a playlist dict or ``None`` if the user cancels.
|
|
369
372
|
|
|
370
373
|
Parameters
|
|
371
374
|
----------
|
|
@@ -397,6 +400,53 @@ class LexiconClient:
|
|
|
397
400
|
self._logger.info("No playlist selected.")
|
|
398
401
|
return selection
|
|
399
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
|
+
|
|
400
450
|
#endregion
|
|
401
451
|
|
|
402
452
|
# ------------------------------------------------------------------
|
|
@@ -483,7 +533,6 @@ class LexiconClient:
|
|
|
483
533
|
while total_remaining is None or (total_remaining > 0 and get_all):
|
|
484
534
|
page_params = list(params)
|
|
485
535
|
page_params.append(("offset", next_offset))
|
|
486
|
-
print(page_params) # DEBUG
|
|
487
536
|
|
|
488
537
|
try:
|
|
489
538
|
response = requests.get(
|
|
@@ -618,25 +667,32 @@ class LexiconClient:
|
|
|
618
667
|
track_ids: Iterable[int],
|
|
619
668
|
*,
|
|
620
669
|
max_workers: int = 5,
|
|
670
|
+
show_progress: bool = True,
|
|
621
671
|
timeout: Optional[int] = None,
|
|
622
|
-
) -> list[dict]:
|
|
623
|
-
"""
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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``.
|
|
629
679
|
"""
|
|
680
|
+
|
|
630
681
|
track_ids = list(track_ids)
|
|
631
|
-
results: list[dict] = []
|
|
632
682
|
|
|
633
|
-
if not track_ids:
|
|
634
|
-
|
|
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
|
|
635
686
|
|
|
636
|
-
|
|
687
|
+
results: list[dict] = []
|
|
637
688
|
|
|
689
|
+
effective_timeout = timeout or self.default_timeout
|
|
690
|
+
|
|
638
691
|
if max_workers == 0:
|
|
639
|
-
|
|
692
|
+
iterable = track_ids
|
|
693
|
+
if show_progress:
|
|
694
|
+
iterable = tqdm(track_ids, desc="Fetching tracks", unit=" tracks")
|
|
695
|
+
for track_id in iterable:
|
|
640
696
|
info = self.get_track(track_id, timeout=effective_timeout)
|
|
641
697
|
if info:
|
|
642
698
|
results.append(info)
|
|
@@ -647,8 +703,15 @@ class LexiconClient:
|
|
|
647
703
|
executor.submit(self.get_track, track_id, timeout=effective_timeout): track_id
|
|
648
704
|
for track_id in track_ids
|
|
649
705
|
}
|
|
650
|
-
|
|
651
|
-
|
|
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:
|
|
652
715
|
track_id = futures[future]
|
|
653
716
|
try:
|
|
654
717
|
info = future.result()
|
|
@@ -657,7 +720,11 @@ class LexiconClient:
|
|
|
657
720
|
except Exception as exc: # noqa: BLE001 - handle worker failures gracefully
|
|
658
721
|
self._logger.warning("Track %s failed during fetch: %s", track_id, exc)
|
|
659
722
|
finally:
|
|
660
|
-
|
|
723
|
+
if progress:
|
|
724
|
+
progress.update(1)
|
|
725
|
+
finally:
|
|
726
|
+
if progress:
|
|
727
|
+
progress.close()
|
|
661
728
|
|
|
662
729
|
return results
|
|
663
730
|
|
|
@@ -690,6 +757,8 @@ class LexiconClient:
|
|
|
690
757
|
self._logger.warning("Response did not contain expected tags structure")
|
|
691
758
|
return None
|
|
692
759
|
|
|
760
|
+
#endregion
|
|
761
|
+
|
|
693
762
|
__all__ = [
|
|
694
763
|
"DEFAULT_HOST",
|
|
695
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
|
],
|
|
@@ -155,21 +155,23 @@ class LexiconApiTests(unittest.TestCase):
|
|
|
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.1 → lexicon_python-0.1.2}/src/lexicon_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|