Coverage for src/epublib/package/spine.py: 95%

62 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-30 17:43 -0300

1from dataclasses import dataclass 

2from typing import Annotated, ClassVar, override 

3 

4from epublib.identifier import EPUBId 

5from epublib.xml_element import XMLAttribute, XMLElement, XMLParent 

6 

7 

8@dataclass(kw_only=True) 

9class SpineItemRef(XMLElement): 

10 """An item reference in the EPUB spine.""" 

11 

12 idref: Annotated[EPUBId, XMLAttribute()] 

13 id: Annotated[str | None, XMLAttribute()] = None 

14 linear: Annotated[bool | None, XMLAttribute()] = None 

15 properties: Annotated[str | None, XMLAttribute()] = None 

16 

17 tag_name: ClassVar[str] = "itemref" 

18 

19 @property 

20 def pk(self) -> EPUBId: 

21 return self.idref 

22 

23 @override 

24 def create_tag(self) -> None: 

25 super().create_tag() 

26 if self.linear: 

27 del self.tag["linear"] 

28 

29 @override 

30 def __post_init__(self): 

31 super().__post_init__() 

32 self.idref = EPUBId(self.idref) 

33 

34 

35class BookSpine(XMLParent[SpineItemRef]): 

36 """The EPUB spine, which defines the linear reading order of the book.""" 

37 

38 tag_name: str | None = "spine" 

39 default_item_type: type[SpineItemRef] = SpineItemRef 

40 

41 @override 

42 def add( # type: ignore[reportIncompatibleMethodOverride] 

43 self, 

44 idref: str | EPUBId, 

45 id: str | None = None, 

46 linear: bool | None = None, 

47 properties: str | None = None, 

48 ): 

49 __ = self.add(idref=idref, id=id, linear=linear, properties=properties) 

50 

51 @override 

52 def insert( # type: ignore[reportIncompatibleMethodOverride] 

53 self, 

54 position: int, 

55 idref: str | EPUBId, 

56 id: str | None = None, 

57 linear: bool | None = None, 

58 properties: str | None = None, 

59 ): 

60 __ = self.insert( 

61 position, 

62 idref, 

63 id, 

64 linear, 

65 properties, 

66 ) 

67 

68 def get_position(self, idref: str | EPUBId) -> int | None: 

69 return next( 

70 (i for i, item in enumerate(self.items) if item.idref == idref), None 

71 ) 

72 

73 @override 

74 def remove(self, idref: str | EPUBId): # type: ignore[reportIncompatibleMethodOverride] 

75 self.remove_item(self[idref]) 

76 

77 def _move_tag(self, item: SpineItemRef, new_position: int): 

78 tags = list(self.tag.find_all("itemref")) 

79 successor = tags[new_position] 

80 actual_new_position = self.tag.index(successor) 

81 

82 __ = item.tag.extract() 

83 __ = self.tag.insert(actual_new_position, item.tag) 

84 

85 def move_item(self, item: int | str | SpineItemRef, new_position: int): 

86 if isinstance(item, (str, int)): 

87 item = self[item] 

88 else: 

89 if item not in self.items: 

90 raise ValueError(f"Item {item} not in spine") 

91 

92 self._items.remove(item) 

93 self._items.insert(new_position, item) 

94 self._move_tag(item, new_position) 

95 

96 def reorder(self, items: list[SpineItemRef]): 

97 new_items_set = {item.idref for item in items} 

98 curr_items_set = {item.idref for item in self.items} 

99 

100 if len(new_items_set) != len(items): 

101 raise ValueError("Duplicate items in new order") 

102 

103 if new_items_set != curr_items_set: 

104 raise ValueError("Items do not match current spine items") 

105 

106 self._items: list[SpineItemRef] = items 

107 

108 self.tag.clear() 

109 for item in items: 

110 __ = self.tag.append(item.tag)