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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexicon-python
3
- Version: 0.1.0
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.0"
7
+ version = "0.1.2"
8
8
  description = "Python client for the Lexicon DJ API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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: Optional[int] = None,
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
- if playlist_type is not None:
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[tuple[list[str], dict]]:
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, current_path
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, new_path
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[tuple[dict, list[str]]]:
368
+ ) -> Optional[dict]:
360
369
  """Fetch playlists and interactively choose one via stdin.
361
370
 
362
- Returns a tuple of (playlist_dict, path) or ``None`` if the user cancels.
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
- Fetch metadata for a collection of tracks.
619
-
620
- Defaults to making 5 requests in parallel.
621
- Set ``max_workers=0`` to fetch one at a time.
622
- 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``.
623
679
  """
680
+
624
681
  track_ids = list(track_ids)
625
- results: list[dict] = []
626
682
 
627
- if not track_ids:
628
- 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
629
686
 
630
- effective_timeout = timeout or self.default_timeout
687
+ results: list[dict] = []
631
688
 
689
+ effective_timeout = timeout or self.default_timeout
690
+
632
691
  if max_workers == 0:
633
- 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:
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
- with tqdm(total=len(futures), desc="Fetching tracks (parallel)", unit=" tracks") as pbar:
645
- 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:
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
- pbar.update(1)
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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lexicon-python
3
- Version: 0.1.0
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
  ],
@@ -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, 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