Coverage for enderchest/instance.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-28 20:32 +0000

1"""Specification of a Minecraft instance""" 

2 

3import re 

4from configparser import SectionProxy 

5from pathlib import Path 

6from typing import NamedTuple 

7 

8from . import config as cfg 

9 

10 

11class InstanceSpec(NamedTuple): 

12 """Specification of a Minecraft instance 

13 

14 Parameters 

15 ---------- 

16 name : str 

17 The "display name" for the instance 

18 root : Path 

19 The path to its ".minecraft" folder 

20 minecraft_versions : list-like of str 

21 The minecraft versions of this instance. This is typically a 1-tuple, 

22 but some loaders (such as the official one) will just comingle all 

23 your assets together across all profiles 

24 modloader : str 

25 The (display) name of the modloader (vanilla corresponds to "") 

26 tags : list-like of str 

27 The tags assigned to this instance, including both the ones assigned 

28 in the launcher (groups) and the ones assigned by hand. 

29 """ 

30 

31 name: str 

32 root: Path 

33 minecraft_versions: tuple[str, ...] 

34 modloader: str 

35 groups_: tuple[str, ...] 

36 tags_: tuple[str, ...] 

37 

38 @classmethod 

39 def from_cfg(cls, section: SectionProxy) -> "InstanceSpec": 

40 """Parse an instance spec as read in from the enderchest config file 

41 

42 Parameters 

43 ---------- 

44 section : dict-like of str to str 

45 The section in the enderchest config as parsed by a ConfigParser 

46 

47 Returns 

48 ------- 

49 InstanceSpec 

50 The resulting InstanceSpec 

51 

52 Raises 

53 ------ 

54 KeyError 

55 If a required key is absent 

56 ValueError 

57 If a required entry cannot be parsed 

58 """ 

59 return cls( 

60 section.name, 

61 Path(section["root"]), 

62 tuple( 

63 parse_version(version.strip()) 

64 for version in cfg.parse_ini_list( 

65 section.get( 

66 "minecraft-version", section.get("minecraft_version", "") 

67 ) 

68 ) 

69 ), 

70 normalize_modloader(section.get("modloader", None))[0], 

71 tuple(cfg.parse_ini_list(section.get("groups", ""))), 

72 tuple(cfg.parse_ini_list(section.get("tags", ""))), 

73 ) 

74 

75 @property 

76 def tags(self): 

77 return tuple(sorted({*self.groups_, *self.tags_})) 

78 

79 

80def normalize_modloader(loader: str | None) -> list[str]: 

81 """Implement common modloader aliases 

82 

83 Parameters 

84 ---------- 

85 loader : str 

86 User-provided modloader name 

87 

88 Returns 

89 ------- 

90 list of str 

91 The modloader values that should be checked against to match the user's 

92 intent 

93 """ 

94 if loader is None: # this would be from the instance spec 

95 return [""] 

96 match loader.lower().replace(" ", "").replace("-", "").replace("_", "").replace( 

97 "/", "" 

98 ): 

99 case "none" | "vanilla" | "minecraftserver": 

100 return [""] 

101 case "fabric" | "fabricloader": 

102 return ["Fabric Loader"] 

103 case "quilt" | "quiltloader": 

104 return ["Quilt Loader"] 

105 case "fabricquilt" | "quiltfabric" | "fabriclike" | "fabriccompatible": 

106 return ["Fabric Loader", "Quilt Loader"] 

107 case "forge" | "forgeloader" | "minecraftforge": 

108 return ["Forge"] 

109 case _: 

110 return [loader.title()] 

111 

112 

113def equals( 

114 minecraft_root: Path, instance: InstanceSpec, other_instance: InstanceSpec 

115) -> bool: 

116 """Determine whether two instances point to the same location 

117 

118 Parameters 

119 ---------- 

120 minecraft_root : Path 

121 The starting location (the parent of where your EnderChest folder lives) 

122 instance : InstanceSpec 

123 the first instance 

124 other_instance : InstanceSpec 

125 the instance to compare it against 

126 

127 Returns 

128 ------- 

129 bool 

130 True if and only if the two instances have the same root, with regards 

131 to the provided `minecraft_root` 

132 """ 

133 path = minecraft_root / instance.root.expanduser() 

134 other_path = minecraft_root / other_instance.root.expanduser() 

135 return path.expanduser().resolve() == other_path.expanduser().resolve() 

136 

137 

138def parse_version(version_string: str) -> str: 

139 """The first release of each major Minecraft version doesn't follow strict 

140 major.minor.patch semver. This method appends the ".0" so that our version 

141 matcher doesn't mess up 

142 

143 Parameters 

144 ---------- 

145 version_string : str 

146 The version read in from the Minecraft instance's config 

147 

148 Returns 

149 ------- 

150 str 

151 Either the original version string or the original version string with 

152 ".0" tacked onto the end of it 

153 

154 Notes 

155 ----- 

156 Regex adapted straight from https://semver.org 

157 """ 

158 if re.match(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$", version_string): 

159 return version_string + ".0" 

160 return version_string 

161 

162 

163def merge(*instances: InstanceSpec) -> InstanceSpec: 

164 """Merge multiple instances, layering information from the ones provided later 

165 on top of the ones provided earlier 

166 

167 Parameters 

168 ---------- 

169 *instances : InstanceSpec 

170 The instances to combine 

171 

172 Returns 

173 ------- 

174 InstanceSpec 

175 The merged instance 

176 

177 Notes 

178 ----- 

179 The resulting merged instance will use: 

180 - the first instance's name 

181 - the union of all non-group tags 

182 - all other data from the last instance 

183 """ 

184 try: 

185 combined_instance = instances[-1] 

186 except IndexError as nothing_to_merge: 

187 raise ValueError( 

188 "Must provide at least one instance to merge" 

189 ) from nothing_to_merge 

190 tags = tuple(sorted(set(sum((instance.tags_ for instance in instances), ())))) 

191 return combined_instance._replace(name=instances[0].name, tags_=tags)