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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexicon-python
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Python client for the Lexicon DJ API
5
5
  Author-email: Garrison Burger <burgerga123@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lexicon-python"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Python client for the Lexicon DJ API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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[tuple[list[str], dict]]:
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, current_path
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, new_path
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[tuple[dict, list[str]]]:
368
+ ) -> Optional[dict]:
366
369
  """Fetch playlists and interactively choose one via stdin.
367
370
 
368
- Returns a tuple of (playlist_dict, path) or ``None`` if the user cancels.
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
- Fetch metadata for a collection of tracks.
625
-
626
- Defaults to making 5 requests in parallel.
627
- Set ``max_workers=0`` to fetch one at a time.
628
- More than 5 workers doesn't seem to improve speed but results may vary.
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
- return results
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
- effective_timeout = timeout or self.default_timeout
687
+ results: list[dict] = []
637
688
 
689
+ effective_timeout = timeout or self.default_timeout
690
+
638
691
  if max_workers == 0:
639
- for track_id in tqdm(track_ids, desc="Fetching tracks", unit=" tracks"):
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
- with tqdm(total=len(futures), desc="Fetching tracks (parallel)", unit=" tracks") as pbar:
651
- for future in as_completed(futures):
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
- pbar.update(1)
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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexicon-python
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Python client for the Lexicon DJ API
5
5
  Author-email: Garrison Burger <burgerga123@gmail.com>
6
6
  License-Expression: MIT
@@ -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, path = self.client.choose_playlist(flat=False, show_counts=False, input_func=lambda _: next(inputs))
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((playlist, path), ({"id": 7, "trackIds": [1, 2, 3]}, ["Folder 2", "Playlist 3"]))
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
- path, chosen = self.client.choose_playlist(flat=False, show_counts=False, input_func=lambda _: next(inputs))
194
+ playlist = self.client.choose_playlist(flat=False, show_counts=False, input_func=lambda _: next(inputs))
193
195
 
194
- self.assertIsNotNone(chosen)
195
- self.assertEqual((path, chosen), ({"id": 2, "trackIds": [1, 2, 3]}, ["Folder 1"]))
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
- path, chosen = self.client.choose_playlist(flat=True, show_counts=False, input_func=lambda _: next(inputs))
204
+ playlist = self.client.choose_playlist(flat=True, show_counts=False, input_func=lambda _: next(inputs))
203
205
 
204
- self.assertIsNotNone(chosen)
205
- self.assertEqual((path, chosen), ({"id": 8, "trackIds": [1, 2, 3]}, ["Folder 2", "Folder 3"]))
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 Cancel
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