pyrekordbox 0.4.2__py3-none-any.whl → 0.4.3__py3-none-any.whl

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.
pyrekordbox/rbxml.py CHANGED
@@ -10,6 +10,8 @@ import urllib.parse
10
10
  import xml.etree.cElementTree as xml
11
11
  from abc import abstractmethod
12
12
  from collections import abc
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Dict, Iterator, List, Set, Union
13
15
 
14
16
  import bidict
15
17
 
@@ -17,6 +19,7 @@ from .utils import pretty_xml
17
19
 
18
20
  logger = logging.getLogger(__name__)
19
21
 
22
+
20
23
  URL_PREFIX = "file://localhost/"
21
24
  POSMARK_TYPE_MAPPING = bidict.bidict(
22
25
  {
@@ -31,21 +34,35 @@ RATING_MAPPING = bidict.bidict({"0": 0, "51": 1, "102": 2, "153": 3, "204": 4, "
31
34
  NODE_KEYTYPE_MAPPING = bidict.bidict({"0": "TrackID", "1": "Location"})
32
35
 
33
36
 
37
+ class XmlElementNotInitializedError(Exception):
38
+ """Raised when an XML element is not initialized."""
39
+
40
+ def __init__(self, name: str) -> None:
41
+ super().__init__(f"XML element {name} is not initialized!")
42
+
43
+
44
+ class RootNodeNotInitializedError(Exception):
45
+ """Raised when the root paylist node is not initialized."""
46
+
47
+ def __init__(self) -> None:
48
+ super().__init__("Playlist root node is not initialized!")
49
+
50
+
34
51
  class XmlDuplicateError(Exception):
35
52
  """Raised when a track already exists in the XML database."""
36
53
 
37
- def __init__(self, key_type, key):
54
+ def __init__(self, key_type: str, key: str) -> None:
38
55
  super().__init__(f"XML database already contains a track with {key_type}={key}")
39
56
 
40
57
 
41
58
  class XmlAttributeKeyError(Exception):
42
- def __init__(self, cls, key, attributes):
59
+ def __init__(self, cls: Any, key: str, attributes: List[str]) -> None:
43
60
  super().__init__(
44
61
  f"{key} is not a valid key for {cls.__name__}! Valid attribs:\n{attributes}"
45
62
  )
46
63
 
47
64
 
48
- def encode_path(path):
65
+ def encode_path(path: Union[str, Path]) -> str:
49
66
  r"""Encodes a file path as URI string.
50
67
 
51
68
  Parameters
@@ -70,7 +87,7 @@ def encode_path(path):
70
87
  return url
71
88
 
72
89
 
73
- def decode_path(url):
90
+ def decode_path(url: str) -> str:
74
91
  r"""Decodes an as URI string encoded file path.
75
92
 
76
93
  Parameters
@@ -95,7 +112,7 @@ def decode_path(url):
95
112
  return os.path.normpath(path)
96
113
 
97
114
 
98
- class AbstractElement(abc.Mapping):
115
+ class AbstractElement(abc.Mapping): # type: ignore[type-arg]
99
116
  """Abstract base class for Rekordbox XML elements.
100
117
 
101
118
  Implements attribute getters and setters for an XML element
@@ -104,17 +121,17 @@ class AbstractElement(abc.Mapping):
104
121
  TAG: str
105
122
  """str: Name of the XML element"""
106
123
 
107
- ATTRIBS: list
124
+ ATTRIBS: List[str]
108
125
  """list[str]: List of all attribute keys of the XML element"""
109
126
 
110
- GETTERS = dict()
127
+ GETTERS: Dict[str, Callable[[Any], Any]] = dict()
111
128
  """dict[str, Callable]: Dictionary of attribute getter conversion methods.
112
129
 
113
130
  See Also
114
131
  --------
115
132
  AbstractElement.get
116
133
  """
117
- SETTERS = dict()
134
+ SETTERS: Dict[str, Callable[[Any], Any]] = dict()
118
135
  """dict[str, Callable]: Dictionary of attribute setter conversion methods.
119
136
 
120
137
  See Also
@@ -122,23 +139,23 @@ class AbstractElement(abc.Mapping):
122
139
  AbstractElement.set
123
140
  """
124
141
 
125
- def __init__(self, element=None, *args, **kwargs):
126
- self._element = element
142
+ def __init__(self, element: xml.Element = None, *args: Any, **kwargs: Any):
143
+ self._element: Union[xml.Element, None] = element
127
144
  if element is None:
128
145
  self._init(*args, **kwargs)
129
146
  else:
130
147
  self._load_subelements()
131
148
 
132
149
  @abstractmethod
133
- def _init(self, *args, **kwargs):
150
+ def _init(self, *args: Any, **kwargs: Any) -> None:
134
151
  """Initializes a new XML element."""
135
152
  pass
136
153
 
137
- def _load_subelements(self):
154
+ def _load_subelements(self) -> None:
138
155
  """Loads the sub-elements of an existing XML element."""
139
156
  pass
140
157
 
141
- def get(self, key, default=None):
158
+ def get(self, key: str, default: Any = None) -> Any:
142
159
  """Returns the value of an attribute of the XML element.
143
160
 
144
161
  The type of the attribute value is converted if a conversion method is specified
@@ -165,6 +182,9 @@ class AbstractElement(abc.Mapping):
165
182
  """
166
183
  if key not in self.ATTRIBS:
167
184
  raise XmlAttributeKeyError(self.__class__, key, self.ATTRIBS)
185
+ if self._element is None:
186
+ raise XmlElementNotInitializedError("_element")
187
+
168
188
  value = self._element.attrib.get(key, default)
169
189
  if value == default:
170
190
  return default
@@ -175,7 +195,7 @@ class AbstractElement(abc.Mapping):
175
195
  pass
176
196
  return value
177
197
 
178
- def set(self, key, value):
198
+ def set(self, key: str, value: Any) -> None:
179
199
  """Sets the value of an attribute of the XML element.
180
200
 
181
201
  The type of the given value is converted before updating the attribute if a
@@ -197,6 +217,8 @@ class AbstractElement(abc.Mapping):
197
217
  """
198
218
  if key not in self.ATTRIBS:
199
219
  raise XmlAttributeKeyError(self.__class__, key, self.ATTRIBS)
220
+ if self._element is None:
221
+ raise XmlElementNotInitializedError("_element")
200
222
  try:
201
223
  # Apply callback
202
224
  value = self.SETTERS[key](value)
@@ -205,15 +227,19 @@ class AbstractElement(abc.Mapping):
205
227
  value = str(value)
206
228
  self._element.attrib[key] = value
207
229
 
208
- def __len__(self):
230
+ def __len__(self) -> int:
209
231
  """int: The number of attributes of the XML element."""
232
+ if self._element is None:
233
+ raise XmlElementNotInitializedError("_element")
210
234
  return len(self._element.attrib)
211
235
 
212
- def __iter__(self):
236
+ def __iter__(self) -> Iterator[str]:
213
237
  """Iterable: An iterator of the attribute keys of the XML element."""
238
+ if self._element is None:
239
+ raise XmlElementNotInitializedError("_element")
214
240
  return iter(self._element.attrib.keys())
215
241
 
216
- def __getitem__(self, key):
242
+ def __getitem__(self, key: str) -> Any:
217
243
  """Returns the raw value of an attribute of the XML element.
218
244
 
219
245
  Parameters
@@ -228,7 +254,7 @@ class AbstractElement(abc.Mapping):
228
254
  """
229
255
  return self.get(key)
230
256
 
231
- def __setitem__(self, key, value):
257
+ def __setitem__(self, key: str, value: Any) -> None:
232
258
  """Sets the raw value of an attribute of the XML element.
233
259
 
234
260
  Parameters
@@ -240,11 +266,11 @@ class AbstractElement(abc.Mapping):
240
266
  """
241
267
  self.set(key, value)
242
268
 
243
- def __getattr__(self, key):
269
+ def __getattr__(self, key: str) -> Any:
244
270
  """Returns the raw value of an attribute of the XML element (same as `get`)."""
245
271
  return self.get(key)
246
272
 
247
- def __repr__(self):
273
+ def __repr__(self) -> str:
248
274
  return f"<{self.__class__.__name__}()>"
249
275
 
250
276
 
@@ -271,10 +297,20 @@ class Tempo(AbstractElement):
271
297
  ATTRIBS = ["Inizio", "Bpm", "Metro", "Battito"]
272
298
  GETTERS = {"Inizio": float, "Bpm": float, "Battito": int}
273
299
 
274
- def __init__(self, parent=None, Inizio=0.0, Bpm=0.0, Metro="4/4", Battito=1, element=None):
300
+ def __init__(
301
+ self,
302
+ parent: xml.Element = None,
303
+ Inizio: float = 0.0,
304
+ Bpm: float = 0.0,
305
+ Metro: str = "4/4",
306
+ Battito: int = 1,
307
+ element: xml.Element = None,
308
+ ):
275
309
  super().__init__(element, parent, Inizio, Bpm, Metro, Battito)
276
310
 
277
- def _init(self, parent, inizio, bpm, metro, battito):
311
+ def _init(
312
+ self, parent: xml.Element, inizio: float, bpm: float, metro: str, battito: int
313
+ ) -> None:
278
314
  attrib = {
279
315
  "Inizio": str(inizio),
280
316
  "Bpm": str(bpm),
@@ -283,7 +319,7 @@ class Tempo(AbstractElement):
283
319
  }
284
320
  self._element = xml.SubElement(parent, self.TAG, attrib=attrib)
285
321
 
286
- def __repr__(self):
322
+ def __repr__(self) -> str:
287
323
  args = ", ".join(
288
324
  [
289
325
  f"Inizio={self.Inizio}",
@@ -327,20 +363,24 @@ class PositionMark(AbstractElement):
327
363
 
328
364
  def __init__(
329
365
  self,
330
- parent=None,
331
- Name="",
332
- Type="cue",
333
- Start=0.0,
334
- End=None,
335
- Num=-1,
336
- element=None,
366
+ parent: xml.Element = None,
367
+ Name: str = "",
368
+ Type: str = "cue",
369
+ Start: float = 0.0,
370
+ End: float = None,
371
+ Num: int = -1,
372
+ element: xml.Element = None,
337
373
  ):
338
374
  super().__init__(element, parent, Name, Type, Start, End, Num)
339
375
 
340
- def _init(self, parent, name, type_, start, end, num):
376
+ def _init(
377
+ self, parent: xml.Element, name: str, type_: str, start: float, end: float, num: int
378
+ ) -> None:
379
+ if type_ not in POSMARK_TYPE_MAPPING.inv:
380
+ raise ValueError(f"Type '{type_}' is not supported!")
341
381
  attrib = {
342
382
  "Name": name,
343
- "Type": POSMARK_TYPE_MAPPING.inv.get(type_), # noqa
383
+ "Type": POSMARK_TYPE_MAPPING.inv.get(type_, "cue"),
344
384
  "Start": str(start),
345
385
  "Num": str(num),
346
386
  }
@@ -348,7 +388,7 @@ class PositionMark(AbstractElement):
348
388
  attrib["End"] = str(end)
349
389
  self._element = xml.SubElement(parent, self.TAG, attrib=attrib)
350
390
 
351
- def __repr__(self):
391
+ def __repr__(self) -> str:
352
392
  args = ", ".join(
353
393
  [
354
394
  f"Name={self.Name}",
@@ -484,20 +524,31 @@ class Track(AbstractElement):
484
524
 
485
525
  SETTERS = {"Rating": RATING_MAPPING.inv.get, "Location": encode_path} # noqa
486
526
 
487
- def __init__(self, parent=None, Location="", element=None, **kwargs):
488
- self.tempos = list()
489
- self.marks = list()
527
+ def __init__(
528
+ self,
529
+ parent: xml.Element = None,
530
+ Location: Union[str, Path] = "",
531
+ element: xml.Element = None,
532
+ **kwargs: Any,
533
+ ):
534
+ self.tempos: List[Tempo] = list()
535
+ self.marks: List[PositionMark] = list()
490
536
  super().__init__(element, parent, Location, **kwargs)
491
537
 
492
- def _init(self, parent, Location, **kwargs):
538
+ def _init(self, parent: xml.Element, Location: Union[str, Path] = "", **kwargs: Any) -> None:
493
539
  attrib = {"Location": encode_path(Location)}
494
540
  for key, val in kwargs.items():
495
541
  if key not in self.ATTRIBS:
496
542
  raise XmlAttributeKeyError(self.__class__, key, self.ATTRIBS)
497
543
  attrib[key] = str(val)
498
- self._element = xml.SubElement(parent, self.TAG, attrib=attrib)
544
+ element = xml.SubElement(parent, self.TAG, attrib=attrib)
545
+ if element is None:
546
+ raise RuntimeError("XML element is not initialized!")
547
+ self._element = element
499
548
 
500
- def _load_subelements(self):
549
+ def _load_subelements(self) -> None:
550
+ if self._element is None:
551
+ raise XmlElementNotInitializedError("_element")
501
552
  tempo_elements = self._element.findall(f"{Tempo.TAG}")
502
553
  if tempo_elements is not None:
503
554
  self.tempos = [Tempo(element=el) for el in tempo_elements]
@@ -505,7 +556,7 @@ class Track(AbstractElement):
505
556
  if mark_elements is not None:
506
557
  self.marks = [PositionMark(element=el) for el in mark_elements]
507
558
 
508
- def add_tempo(self, Inizio, Bpm, Metro, Battito):
559
+ def add_tempo(self, Inizio: float, Bpm: float, Metro: str, Battito: int) -> Tempo:
509
560
  """Adds a new ``Tempo`` XML element to the track element.
510
561
 
511
562
  Parameters
@@ -533,7 +584,14 @@ class Track(AbstractElement):
533
584
  self.tempos.append(tempo)
534
585
  return tempo
535
586
 
536
- def add_mark(self, Name="", Type="cue", Start=0.0, End=None, Num=-1):
587
+ def add_mark(
588
+ self,
589
+ Name: str = "",
590
+ Type: str = "cue",
591
+ Start: float = 0.0,
592
+ End: float = None,
593
+ Num: int = -1,
594
+ ) -> PositionMark:
537
595
  """Adds a new ``PositionMark`` XML element to the track element.
538
596
 
539
597
  Parameters
@@ -564,7 +622,7 @@ class Track(AbstractElement):
564
622
  self.marks.append(mark)
565
623
  return mark
566
624
 
567
- def __repr__(self):
625
+ def __repr__(self) -> str:
568
626
  return f"<{self.__class__.__name__}(Location={self.Location})>"
569
627
 
570
628
 
@@ -587,28 +645,30 @@ class Node:
587
645
  FOLDER = 0
588
646
  PLAYLIST = 1
589
647
 
590
- def __init__(self, parent=None, element=None, **attribs):
648
+ def __init__(self, parent: xml.Element = None, element: xml.Element = None, **attribs: Any):
649
+ if element is None:
650
+ if parent is None:
651
+ raise ValueError("Either parent or element must be given!")
652
+ element = xml.SubElement(parent, self.TAG, attrib=attribs)
591
653
  self._parent = parent
592
654
  self._element = element
593
- if element is None:
594
- self._element = xml.SubElement(parent, self.TAG, attrib=attribs)
595
655
 
596
656
  @classmethod
597
- def folder(cls, parent, name):
657
+ def folder(cls, parent: xml.Element, name: str) -> "Node":
598
658
  """Initializes a playlist folder node XML element.
599
659
 
600
660
  Parameters
601
661
  ----------
602
- parent : xml.Element
662
+ parent : Node
603
663
  The parent node XML element of the new playlist folder node.
604
664
  name : str
605
665
  The name of the playlist folder node.
606
666
  """
607
667
  attrib = {"Name": name, "Type": str(cls.FOLDER), "Count": "0"}
608
- return cls(parent, **attrib)
668
+ return cls(parent, None, **attrib)
609
669
 
610
670
  @classmethod
611
- def playlist(cls, parent, name, keytype="TrackID"):
671
+ def playlist(cls, parent: xml.Element, name: str, keytype: str = "TrackID") -> "Node":
612
672
  """Initializes a playlist node XML element.
613
673
 
614
674
  Parameters
@@ -621,61 +681,70 @@ class Node:
621
681
  The key type used by the playlist node. Can be 'TrackID' or 'Location'
622
682
  (file path of the track).
623
683
  """
684
+ if keytype not in NODE_KEYTYPE_MAPPING.inv:
685
+ raise ValueError(f"Key type '{keytype}' is not supported!")
624
686
  attrib = {
625
687
  "Name": name,
626
688
  "Type": str(cls.PLAYLIST),
627
- "KeyType": NODE_KEYTYPE_MAPPING.inv[keytype], # noqa
689
+ "KeyType": NODE_KEYTYPE_MAPPING.inv.get(keytype, "TrackID"),
628
690
  "Entries": "0",
629
691
  }
630
- return cls(parent, **attrib)
692
+ return cls(parent, None, **attrib)
631
693
 
632
694
  @property
633
- def parent(self):
634
- """Node: The parent of the node."""
695
+ def parent(self) -> Union[xml.Element, None]:
696
+ """xml.Element: The parent of the node."""
635
697
  return self._parent
636
698
 
637
699
  @property
638
- def name(self):
639
- """str: The name of node."""
640
- return self._element.attrib.get("Name")
700
+ def name(self) -> str:
701
+ """str: The name of the node."""
702
+ value = self._element.attrib.get("Name")
703
+ if value is None:
704
+ raise ValueError("Name element has no value")
705
+ return value
641
706
 
642
707
  @property
643
- def type(self):
708
+ def type(self) -> int:
644
709
  """int: The type of the node (0=folder or 1=playlist)."""
645
- return int(self._element.attrib.get("Type"))
710
+ type_ = self._element.attrib.get("Type")
711
+ if type_ is None:
712
+ raise ValueError("Type element has no value")
713
+ return int(type_)
646
714
 
647
715
  @property
648
- def count(self):
716
+ def count(self) -> int:
649
717
  """int: The number of attributes of the XML element."""
650
718
  return int(self._element.attrib.get("Count", 0))
651
719
 
652
720
  @property
653
- def entries(self):
721
+ def entries(self) -> int:
654
722
  """int: The number of entries of the node."""
655
723
  return int(self._element.attrib.get("Entries", 0))
656
724
 
657
725
  @property
658
- def key_type(self):
726
+ def key_type(self) -> str:
659
727
  """str: The type of key used by the playlist node."""
660
- return NODE_KEYTYPE_MAPPING.get(self._element.attrib.get("KeyType"))
728
+ keytype: str = NODE_KEYTYPE_MAPPING[self._element.attrib.get("KeyType", "0")]
729
+ return keytype
661
730
 
662
731
  @property
663
- def is_folder(self):
732
+ def is_folder(self) -> bool:
664
733
  """bool: True if the node is a playlist folder, false if otherwise."""
665
734
  return self.type == self.FOLDER
666
735
 
667
736
  @property
668
- def is_playlist(self):
737
+ def is_playlist(self) -> bool:
669
738
  """bool: True if the node is a playlist, false if otherwise."""
670
739
  return self.type == self.PLAYLIST
671
740
 
672
- def _update_count(self):
741
+ def _update_count(self) -> None:
673
742
  self._element.attrib["Count"] = str(len(self._element))
674
743
 
675
- def _update_entries(self):
744
+ def _update_entries(self) -> None:
676
745
  self._element.attrib["Entries"] = str(len(self._element))
677
746
 
678
- def get_node(self, i):
747
+ def get_node(self, i: int) -> "Node":
679
748
  """Returns the i-th sub-Node of the current node.
680
749
 
681
750
  Parameters
@@ -687,9 +756,9 @@ class Node:
687
756
  -------
688
757
  subnode : Node
689
758
  """
690
- return Node(self, element=self._element.findall(f"{self.TAG}[{i + 1}]"))
759
+ return Node(self._element, element=self._element.find(f"{self.TAG}[{i + 1}]"))
691
760
 
692
- def get_playlist(self, name):
761
+ def get_playlist(self, name: str) -> "Node":
693
762
  """Returns the sub-Node with the given name.
694
763
 
695
764
  Parameters
@@ -701,9 +770,9 @@ class Node:
701
770
  -------
702
771
  subnode : Node
703
772
  """
704
- return Node(self, element=self._element.find(f'.//{self.TAG}[@Name="{name}"]'))
773
+ return Node(self._element, element=self._element.find(f'.//{self.TAG}[@Name="{name}"]'))
705
774
 
706
- def get_playlists(self):
775
+ def get_playlists(self) -> List["Node"]:
707
776
  """Returns all sub-nodes that are playlists.
708
777
 
709
778
  Returns
@@ -711,9 +780,9 @@ class Node:
711
780
  playlists : list[Node]
712
781
  The playlist nodes in the current node.
713
782
  """
714
- return [Node(self, element=el) for el in self._element]
783
+ return [Node(self._element, element=el) for el in self._element]
715
784
 
716
- def add_playlist_folder(self, name):
785
+ def add_playlist_folder(self, name: str) -> "Node":
717
786
  """Add a new playlist folder as child to this node.
718
787
 
719
788
  Parameters
@@ -738,7 +807,7 @@ class Node:
738
807
  self._update_count()
739
808
  return node
740
809
 
741
- def add_playlist(self, name, keytype="TrackID"):
810
+ def add_playlist(self, name: str, keytype: str = "TrackID") -> "Node":
742
811
  """Add a new playlist as child to this node.
743
812
 
744
813
  Parameters
@@ -766,7 +835,7 @@ class Node:
766
835
  self._update_count()
767
836
  return node
768
837
 
769
- def remove_playlist(self, name):
838
+ def remove_playlist(self, name: str) -> None:
770
839
  """Removes a playlist from the playlist folder node.
771
840
 
772
841
  Parameters
@@ -779,7 +848,7 @@ class Node:
779
848
  self._update_count()
780
849
  self._update_entries()
781
850
 
782
- def add_track(self, key):
851
+ def add_track(self, key: Union[int, str]) -> xml.Element:
783
852
  """Adds a new track to the playlist node.
784
853
 
785
854
  Parameters
@@ -793,10 +862,12 @@ class Node:
793
862
  The newly created playlist track element.
794
863
  """
795
864
  el = xml.SubElement(self._element, Track.TAG, attrib={"Key": str(key)})
865
+ if el is None:
866
+ raise RuntimeError("XML element is not initialized!")
796
867
  self._update_entries()
797
868
  return el
798
869
 
799
- def remove_track(self, key):
870
+ def remove_track(self, key: Union[int, str]) -> xml.Element:
800
871
  """Removes a track from the playlist node.
801
872
 
802
873
  Parameters
@@ -806,11 +877,13 @@ class Node:
806
877
  playlist node.
807
878
  """
808
879
  el = self._element.find(f'{Track.TAG}[@Key="{key}"]')
880
+ if el is None:
881
+ raise ValueError(f"Track key {key} not found.")
809
882
  self._element.remove(el)
810
883
  self._update_entries()
811
884
  return el
812
885
 
813
- def get_tracks(self):
886
+ def get_tracks(self) -> List[Union[int, str]]:
814
887
  """Returns the keys of all tracks contained in the playlist node.
815
888
 
816
889
  Returns
@@ -824,21 +897,23 @@ class Node:
824
897
  elements = self._element.findall(f".//{Track.TAG}")
825
898
  items = list()
826
899
  for el in elements:
827
- val = el.attrib["Key"]
900
+ val: Union[int, str] = el.attrib["Key"]
828
901
  if self.key_type == "TrackID":
829
902
  val = int(val)
830
903
  items.append(val)
831
904
  return items
832
905
 
833
- def get_track(self, key):
906
+ def get_track(self, key: str) -> Union[int, str]:
834
907
  """Returns the formatted key of the track."""
835
908
  el = self._element.find(f'{Track.TAG}[@Key="{key}"]')
836
- val = el.attrib["Key"]
909
+ if el is None:
910
+ raise ValueError(f"Track key {key} not found.")
911
+ val: Union[int, str] = el.attrib["Key"]
837
912
  if self.key_type == "TrackID":
838
913
  val = int(val)
839
914
  return val
840
915
 
841
- def treestr(self, indent=4, lvl=0):
916
+ def treestr(self, indent: int = 4, lvl: int = 0) -> str:
842
917
  """returns a formatted string of the node tree strucutre.
843
918
 
844
919
  Parameters
@@ -863,10 +938,12 @@ class Node:
863
938
  string += node.treestr(indent, lvl + 1)
864
939
  return string
865
940
 
866
- def __eq__(self, other):
941
+ def __eq__(self, other: Any) -> bool:
942
+ if not isinstance(other, Node):
943
+ raise NotImplementedError()
867
944
  return self.parent == other.parent and self.name == other.name
868
945
 
869
- def __repr__(self):
946
+ def __repr__(self) -> str:
870
947
  return f"<{self.__class__.__name__}({self.name})>"
871
948
 
872
949
 
@@ -908,17 +985,23 @@ class RekordboxXml:
908
985
  PLST_TAG = "PLAYLISTS"
909
986
  COLL_TAG = "COLLECTION"
910
987
 
911
- def __init__(self, path=None, name=None, version=None, company=None):
912
- self._root = None
913
- self._product = None
914
- self._collection = None
915
- self._playlists = None
916
- self._root_node = None
988
+ def __init__(
989
+ self,
990
+ path: Union[str, Path] = None,
991
+ name: str = None,
992
+ version: str = None,
993
+ company: str = None,
994
+ ):
995
+ self._root: Union[xml.Element, None] = None
996
+ self._product: Union[xml.Element, None] = None
997
+ self._collection: Union[xml.Element, None] = None
998
+ self._playlists: Union[xml.Element, None] = None
999
+ self._root_node: Union[Node, None] = None
917
1000
 
918
1001
  self._last_id = 0
919
1002
  # Used for fast duplicate check
920
- self._locations = set()
921
- self._ids = set()
1003
+ self._locations: Set[str] = set()
1004
+ self._ids: Set[int] = set()
922
1005
 
923
1006
  if path is not None:
924
1007
  self._parse(path)
@@ -926,36 +1009,48 @@ class RekordboxXml:
926
1009
  self._init(name, version, company)
927
1010
 
928
1011
  @property
929
- def frmt_version(self):
1012
+ def frmt_version(self) -> str:
930
1013
  """str : The version of the Rekordbox XML format."""
931
- return self._root.attrib["Version"]
1014
+ if self._root is None:
1015
+ raise XmlElementNotInitializedError("_root")
1016
+ return self._root.attrib.get("Version", "")
932
1017
 
933
1018
  @property
934
- def product_name(self):
1019
+ def product_name(self) -> str:
935
1020
  """str : The product name that will be displayed in the software."""
936
- return self._product.attrib.get("Name")
1021
+ if self._product is None:
1022
+ raise XmlElementNotInitializedError("_product")
1023
+ return self._product.attrib.get("Name", "")
937
1024
 
938
1025
  @property
939
- def product_version(self):
1026
+ def product_version(self) -> str:
940
1027
  """str : The product version."""
941
- return self._product.attrib.get("Version")
1028
+ if self._product is None:
1029
+ raise XmlElementNotInitializedError("_product")
1030
+ return self._product.attrib.get("Version", "")
942
1031
 
943
1032
  @property
944
- def product_company(self):
1033
+ def product_company(self) -> str:
945
1034
  """str : The company name."""
946
- return self._product.attrib.get("Company")
1035
+ if self._product is None:
1036
+ raise XmlElementNotInitializedError("_product")
1037
+ return self._product.attrib.get("Company", "")
947
1038
 
948
1039
  @property
949
- def num_tracks(self):
950
- """str : The number of tracks in the collection."""
951
- return int(self._collection.attrib.get("Entries"))
1040
+ def num_tracks(self) -> int:
1041
+ """int : The number of tracks in the collection."""
1042
+ if self._collection is None:
1043
+ raise XmlElementNotInitializedError("_collection")
1044
+ return int(self._collection.attrib.get("Entries", "0"))
952
1045
 
953
1046
  @property
954
- def root_playlist_folder(self):
1047
+ def root_playlist_folder(self) -> Node:
955
1048
  """Node: The node of the root playlist folder containing all other nodes."""
1049
+ if self._root_node is None:
1050
+ raise RootNodeNotInitializedError()
956
1051
  return self._root_node
957
1052
 
958
- def _parse(self, path):
1053
+ def _parse(self, path: Union[str, Path]) -> None:
959
1054
  """Parse an existing XML file.
960
1055
 
961
1056
  Parameters
@@ -965,13 +1060,26 @@ class RekordboxXml:
965
1060
  """
966
1061
  tree = xml.parse(str(path))
967
1062
  self._root = tree.getroot()
968
- self._product = self._root.find(self.PRDT_TAG)
969
- self._collection = self._root.find(self.COLL_TAG)
970
- self._playlists = self._root.find(self.PLST_TAG)
1063
+
1064
+ product = self._root.find(self.PRDT_TAG)
1065
+ collection = self._root.find(self.COLL_TAG)
1066
+ playlists = self._root.find(self.PLST_TAG)
1067
+ if product is None:
1068
+ raise RuntimeError(f"No product found in {path}")
1069
+ if collection is None:
1070
+ raise RuntimeError(f"No collection found in {path}")
1071
+ if playlists is None:
1072
+ raise RuntimeError(f"No playlists found in {path}")
1073
+
1074
+ self._product = product
1075
+ self._collection = collection
1076
+ self._playlists = playlists
971
1077
  self._root_node = Node(element=self._playlists.find(Node.TAG))
972
1078
  self._update_cache()
973
1079
 
974
- def _init(self, name=None, version=None, company=None, frmt_version=None):
1080
+ def _init(
1081
+ self, name: str = None, version: str = None, company: str = None, frmt_version: str = None
1082
+ ) -> None:
975
1083
  """Initialize a new XML file."""
976
1084
  frmt_version = frmt_version or "1.0.0"
977
1085
  name = name or "pyrekordbox"
@@ -994,7 +1102,7 @@ class RekordboxXml:
994
1102
  if track_ids:
995
1103
  self._last_id = max(track_ids)
996
1104
 
997
- def get_tracks(self):
1105
+ def get_tracks(self) -> List[Track]:
998
1106
  """Returns the tracks in the collection of the XML file.
999
1107
 
1000
1108
  Returns
@@ -1002,10 +1110,14 @@ class RekordboxXml:
1002
1110
  tracks : list of Track
1003
1111
  A list of the track objects in the collection.
1004
1112
  """
1113
+ if self._collection is None:
1114
+ raise XmlElementNotInitializedError("_collection")
1005
1115
  elements = self._collection.findall(f".//{Track.TAG}")
1006
1116
  return [Track(element=el) for el in elements]
1007
1117
 
1008
- def get_track(self, index=None, TrackID=None, Location=None):
1118
+ def get_track(
1119
+ self, index: int = None, TrackID: Union[int, str] = None, Location: str = None
1120
+ ) -> Track:
1009
1121
  """Get a track in the collection of the XML file.
1010
1122
 
1011
1123
  Parameters
@@ -1013,7 +1125,7 @@ class RekordboxXml:
1013
1125
  index : int, optional
1014
1126
  If `index` is given, the track with this index in the collection is
1015
1127
  returned.
1016
- TrackID : int, optional
1128
+ TrackID : int or str, optional
1017
1129
  If `TrackID` is given, the track with this ID in the collection is
1018
1130
  returned.
1019
1131
  Location : str, optional
@@ -1042,19 +1154,20 @@ class RekordboxXml:
1042
1154
  >>> track = file.get_track(TrackID=1)
1043
1155
 
1044
1156
  """
1045
- if index is None and TrackID is None and Location is None:
1046
- raise ValueError("Either index, TrackID or Location has to be specified!")
1047
-
1157
+ if self._collection is None:
1158
+ raise XmlElementNotInitializedError("_collection")
1048
1159
  if TrackID is not None:
1049
1160
  el = self._collection.find(f'.//{Track.TAG}[@TrackID="{TrackID}"]')
1050
1161
  elif Location is not None:
1051
1162
  encoded = encode_path(Location)
1052
1163
  el = self._collection.find(f'.//{Track.TAG}[@Location="{encoded}"]')
1053
- else:
1164
+ elif index is not None:
1054
1165
  el = self._collection.find(f".//{Track.TAG}[{index + 1}]")
1166
+ else:
1167
+ raise ValueError("Either index, TrackID or Location has to be specified!")
1055
1168
  return Track(element=el)
1056
1169
 
1057
- def get_track_ids(self):
1170
+ def get_track_ids(self) -> List[int]:
1058
1171
  """Returns the `TrackID` of all tracks in the collection of the XML file.
1059
1172
 
1060
1173
  Returns
@@ -1062,10 +1175,12 @@ class RekordboxXml:
1062
1175
  ids : list of int
1063
1176
  The ID's of all tracks.
1064
1177
  """
1178
+ if self._collection is None:
1179
+ raise XmlElementNotInitializedError("_collection")
1065
1180
  elements = self._collection.findall(f".//{Track.TAG}")
1066
1181
  return [int(el.attrib["TrackID"]) for el in elements]
1067
1182
 
1068
- def get_playlist(self, *names):
1183
+ def get_playlist(self, *names: str) -> Node:
1069
1184
  """Returns a playlist or playlist folder with the given path.
1070
1185
 
1071
1186
  Parameters
@@ -1085,6 +1200,8 @@ class RekordboxXml:
1085
1200
  >>> playlist = file.get_playlist("Folder", "Sub Playlist")
1086
1201
 
1087
1202
  """
1203
+ if self._root_node is None:
1204
+ raise RootNodeNotInitializedError()
1088
1205
  node = self._root_node
1089
1206
  if not names:
1090
1207
  return node
@@ -1097,34 +1214,38 @@ class RekordboxXml:
1097
1214
  # num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1098
1215
  # self._collection.attrib["Entries"] = str(num_tracks)
1099
1216
 
1100
- def _increment_track_count(self):
1217
+ def _increment_track_count(self) -> None:
1101
1218
  """Increment the track count element."""
1219
+ if self._collection is None:
1220
+ raise XmlElementNotInitializedError("_collection")
1102
1221
  old = int(self._collection.attrib["Entries"])
1103
1222
  self._collection.attrib["Entries"] = str(old + 1)
1104
1223
 
1105
- def _decrement_track_count(self):
1224
+ def _decrement_track_count(self) -> None:
1106
1225
  """Decrement the track count element."""
1226
+ if self._collection is None:
1227
+ raise XmlElementNotInitializedError("_collection")
1107
1228
  old = int(self._collection.attrib["Entries"])
1108
1229
  self._collection.attrib["Entries"] = str(old - 1)
1109
1230
 
1110
- def _add_cache(self, track):
1231
+ def _add_cache(self, track: Track) -> None:
1111
1232
  """Add the TrackID and Location to the cache."""
1112
1233
  self._locations.add(track.Location)
1113
1234
  self._ids.add(track.TrackID)
1114
1235
 
1115
- def _remove_cache(self, track):
1236
+ def _remove_cache(self, track: Track) -> None:
1116
1237
  """Remove the TrackID and Location from the cache."""
1117
1238
  self._locations.remove(track.Location)
1118
1239
  self._ids.remove(track.TrackID)
1119
1240
 
1120
- def _update_cache(self):
1241
+ def _update_cache(self) -> None:
1121
1242
  """Update the cache with the current tracks in the collection."""
1122
1243
  self._locations.clear()
1123
1244
  self._ids.clear()
1124
1245
  for track in self.get_tracks():
1125
1246
  self._add_cache(track)
1126
1247
 
1127
- def add_track(self, location, **kwargs):
1248
+ def add_track(self, location: Union[str, Path], **kwargs: Any) -> Track:
1128
1249
  """Add a new track element to the Rekordbox XML collection.
1129
1250
 
1130
1251
  Parameters
@@ -1157,7 +1278,7 @@ class RekordboxXml:
1157
1278
  # Check that Location and TrackID are unique
1158
1279
  track_id = kwargs["TrackID"]
1159
1280
  if os.path.normpath(location) in self._locations:
1160
- raise XmlDuplicateError("Location", location)
1281
+ raise XmlDuplicateError("Location", str(location))
1161
1282
  if track_id in self._ids:
1162
1283
  raise XmlDuplicateError("TrackID", track_id)
1163
1284
 
@@ -1168,7 +1289,7 @@ class RekordboxXml:
1168
1289
  self._add_cache(track)
1169
1290
  return track
1170
1291
 
1171
- def remove_track(self, track):
1292
+ def remove_track(self, track: Track) -> None:
1172
1293
  """Remove a track element from the Rekordbox XML collection.
1173
1294
 
1174
1295
  Parameters
@@ -1183,11 +1304,15 @@ class RekordboxXml:
1183
1304
  >>> file.remove_track(t)
1184
1305
 
1185
1306
  """
1307
+ if self._collection is None:
1308
+ raise XmlElementNotInitializedError("_collection")
1309
+ if track._element is None: # noqa
1310
+ raise XmlElementNotInitializedError("track._element")
1186
1311
  self._collection.remove(track._element) # noqa
1187
1312
  self._decrement_track_count()
1188
1313
  self._remove_cache(track)
1189
1314
 
1190
- def add_playlist_folder(self, name):
1315
+ def add_playlist_folder(self, name: str) -> Node:
1191
1316
  """Add a new top-level playlist folder to the XML collection.
1192
1317
 
1193
1318
  Parameters
@@ -1209,10 +1334,12 @@ class RekordboxXml:
1209
1334
  >>> file = RekordboxXml("database.xml")
1210
1335
  >>> file.add_playlist_folder("New Folder")
1211
1336
  """
1337
+ if self._root_node is None:
1338
+ raise RootNodeNotInitializedError()
1212
1339
  return self._root_node.add_playlist_folder(name)
1213
1340
 
1214
- def add_playlist(self, name, keytype="TrackID"):
1215
- """Add a new top-level playlist to the XML collection.
1341
+ def add_playlist(self, name: str, keytype: str = "TrackID") -> Node:
1342
+ """Adds a new top-level playlist to the XML collection.
1216
1343
 
1217
1344
  Parameters
1218
1345
  ----------
@@ -1242,45 +1369,97 @@ class RekordboxXml:
1242
1369
 
1243
1370
  >>> file.add_playlist("New Playlist 2", keytype="Location")
1244
1371
  """
1372
+ if self._root_node is None:
1373
+ raise RootNodeNotInitializedError()
1245
1374
  return self._root_node.add_playlist(name, keytype)
1246
1375
 
1247
- def tostring(self, indent=None):
1248
- """Returns the contents of the XML file as a string.
1376
+ def tostring(self, indent: str = None, encoding: str = "utf-8") -> str:
1377
+ r"""Returns the contents of the XML file as a string.
1249
1378
 
1250
1379
  Parameters
1251
1380
  ----------
1252
1381
  indent : str, optional
1253
- The indentation used for formatting the XML file. The default is 3 spaces.
1382
+ The indentation used for formatting the XML file. The default is '\t'.
1383
+ encoding : str, optional
1384
+ The encoding used for the XML file. The default is 'utf-8'.
1254
1385
 
1255
1386
  Returns
1256
1387
  -------
1257
1388
  s : str
1258
1389
  The contents of the XML file
1259
1390
  """
1391
+ if self._collection is None:
1392
+ raise XmlElementNotInitializedError("_collection")
1393
+ if self._root is None:
1394
+ raise XmlElementNotInitializedError("_root")
1395
+
1260
1396
  # Check track count is valid
1261
1397
  num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1262
1398
  n = int(self._collection.attrib["Entries"])
1263
1399
  if n != num_tracks:
1264
1400
  raise ValueError(f"Track count {num_tracks} does not match number of elements {n}")
1265
- # Generate XML string
1266
- return pretty_xml(self._root, indent, encoding="utf-8")
1267
1401
 
1268
- def save(self, path="", indent=None):
1269
- """Saves the contents to an XML file.
1402
+ space = "\t" if indent is None else indent
1403
+ text: str
1404
+ data: bytes
1405
+ try:
1406
+ tree = xml.ElementTree(self._root)
1407
+ xml.indent(tree, space=space, level=0)
1408
+ data = xml.tostring(self._root, encoding=encoding, xml_declaration=True)
1409
+ text = data.decode(encoding)
1410
+ except AttributeError:
1411
+ # For Python < 3.9
1412
+ try:
1413
+ text = pretty_xml(self._root, space, encoding=encoding)
1414
+ except Exception: # noqa
1415
+ # If the pretty_xml function fails, use unformatted XML
1416
+ data = xml.tostring(self._root, encoding=encoding, xml_declaration=True)
1417
+ text = data.decode(encoding)
1418
+ return text
1419
+
1420
+ def save(
1421
+ self, path: Union[str, Path] = "", indent: str = None, encoding: str = "utf-8"
1422
+ ) -> None:
1423
+ r"""Saves the contents to an XML file.
1270
1424
 
1271
1425
  Parameters
1272
1426
  ----------
1273
1427
  path : str or Path, optional
1274
1428
  The path for saving the XML file. The default is the original file.
1275
1429
  indent : str, optional
1276
- The indentation used for formatting the XML element.
1277
- The default is 3 spaces.
1430
+ The indentation used for formatting the XML file. The default is '\t'.
1431
+ encoding : str, optional
1432
+ The encoding used for the XML file. The default is 'utf-8'.
1278
1433
  """
1279
- string = self.tostring(indent)
1280
- with open(path, "w", encoding="utf-8") as fh:
1281
- fh.write(string)
1434
+ if self._collection is None:
1435
+ raise XmlElementNotInitializedError("_collection")
1436
+ if self._root is None:
1437
+ raise XmlElementNotInitializedError("_root")
1438
+
1439
+ # Check track count is valid
1440
+ num_tracks = len(self._collection.findall(f".//{Track.TAG}"))
1441
+ n = int(self._collection.attrib["Entries"])
1442
+ if n != num_tracks:
1443
+ raise ValueError(f"Track count {num_tracks} does not match number of elements {n}")
1282
1444
 
1283
- def __repr__(self):
1445
+ space = "\t" if indent is None else indent
1446
+ try:
1447
+ tree = xml.ElementTree(self._root)
1448
+ xml.indent(tree, space=space, level=0)
1449
+ tree.write(path, encoding=encoding, xml_declaration=True)
1450
+ except AttributeError:
1451
+ # For Python < 3.9
1452
+ try:
1453
+ data: str = pretty_xml(self._root, space, encoding=encoding)
1454
+ with open(path, "w", encoding=encoding) as fh:
1455
+ fh.write(data)
1456
+ except Exception: # noqa
1457
+ # If the pretty_xml function fails, write the XML unformatted
1458
+ text: bytes = xml.tostring(self._root, encoding=encoding, xml_declaration=True)
1459
+ with open(path, "wb") as fh:
1460
+ fh.write(text)
1461
+
1462
+ def __repr__(self) -> str:
1284
1463
  name = self.product_name
1285
1464
  v = self.product_version
1286
1465
  company = self.product_company