Coverage for enderchest/shulker_box.py: 96%

124 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-06 16:00 +0000

1"""Specification and configuration of a shulker box""" 

2import fnmatch 

3import os 

4from pathlib import Path 

5from typing import Any, Iterable, NamedTuple 

6 

7import semantic_version as semver 

8 

9from . import config as cfg 

10from . import filesystem as fs 

11from .instance import InstanceSpec, normalize_modloader 

12from .loggers import CRAFT_LOGGER 

13 

14_DEFAULT_PRIORITY = 0 

15_DEFAULT_LINK_DEPTH = 2 

16_DEFAULT_DO_NOT_LINK = ("shulkerbox.cfg", ".DS_Store") 

17 

18 

19class ShulkerBox(NamedTuple): 

20 """Specification of a shulker box 

21 

22 Parameters 

23 ---------- 

24 priority : int 

25 The priority for linking assets in the shulker box (higher priority 

26 boxes are linked last) 

27 name : str 

28 The name of the shulker box (which is incidentally used to break 

29 priority ties) 

30 root : Path 

31 The path to the root of the shulker box 

32 match_criteria : list-like of tuples 

33 The parameters for matching instances to this shulker box. Each element 

34 consists of: 

35 

36 - the name of the condition 

37 - the matching values for that condition 

38 

39 The logic applied is that an instance must match at least one value 

40 for each condition (so it's ANDing a collection of ORs) 

41 link_folders : list-like of str 

42 The folders that should be linked in their entirety 

43 max_link_depth : int, optional 

44 By default, non-root-level folders (that is, folders inside of folders) 

45 will be treated as files for the purpose of linking. Put another way, 

46 only files with a depth of 2 or less from the shulker root will be 

47 linked. This behavior can be overridden by explicitly setting 

48 the `max_link_depth` value, but **this feature is highly experimental**, 

49 so use it at your own risk. 

50 do_not_link : list-like of str, optional 

51 Glob patterns of files that should not be linked. By default, this list 

52 comprises `shulkerbox.cfg` and `.DS_Store` (for all you mac gamers). 

53 

54 Notes 

55 ----- 

56 A shulker box specification is immutable, so making changes (such as 

57 updating the match criteria) can only be done on copies created via the 

58 `_replace` method, inherited from the NamedTuple parent class. 

59 """ 

60 

61 priority: int 

62 name: str 

63 root: Path 

64 match_criteria: tuple[tuple[str, tuple[str, ...]], ...] 

65 link_folders: tuple[str, ...] 

66 max_link_depth: int = _DEFAULT_LINK_DEPTH 

67 do_not_link: tuple[str, ...] = _DEFAULT_DO_NOT_LINK 

68 

69 @classmethod 

70 def from_cfg(cls, config_file: Path) -> "ShulkerBox": 

71 """Parse a shulker box from its config file 

72 

73 Parameters 

74 ---------- 

75 config_file : Path 

76 The path to the config file 

77 

78 Returns 

79 ------- 

80 ShulkerBox 

81 The resulting ShulkerBox 

82 

83 Raises 

84 ------ 

85 ValueError 

86 If the config file at that location cannot be parsed 

87 FileNotFoundError 

88 If there is no config file at the specified location 

89 """ 

90 priority = 0 

91 max_link_depth = 2 

92 root = config_file.parent 

93 name = root.name 

94 config = cfg.read_cfg(config_file) 

95 

96 match_criteria: dict[str, tuple[str, ...]] = {} 

97 

98 for section in config.sections(): 

99 normalized = ( 

100 section.lower().replace(" ", "").replace("-", "").replace("_", "") 

101 ) 

102 if normalized.endswith("s"): 

103 normalized = normalized[:-1] # lazy de-pluralization 

104 if normalized in ("linkfolder", "folder"): 

105 normalized = "link-folders" 

106 if normalized in ("donotlink",): 

107 normalized = "do-not-link" 

108 if normalized in ("minecraft", "version", "minecraftversion"): 

109 normalized = "minecraft" 

110 if normalized in ("modloader", "loader"): 

111 normalized = "modloader" 

112 if normalized in ("instance", "tag", "host"): 

113 normalized += "s" # lazy re-pluralization 

114 

115 if normalized == "propertie": # lulz 

116 # TODO check to make sure properties hasn't been read before 

117 # most of this section gets ignored 

118 priority = config[section].getint("priority", _DEFAULT_PRIORITY) 

119 max_link_depth = config[section].getint( 

120 "max-link-depth", _DEFAULT_LINK_DEPTH 

121 ) 

122 # TODO: support specifying filters (and link-folders) in the properties section 

123 continue 

124 if normalized in match_criteria: 

125 raise ValueError(f"{config_file} specifies {normalized} more than once") 

126 

127 if normalized == "minecraft": 

128 minecraft_versions = [] 

129 for key, value in config[section].items(): 

130 if value is None: 

131 minecraft_versions.append(key) 

132 elif key.lower().strip().startswith("version"): 

133 minecraft_versions.append(value) 

134 else: # what happens if you specify ">=1.19" or "=1.12" 

135 minecraft_versions.append("=".join((key, value))) 

136 match_criteria[normalized] = tuple(minecraft_versions) 

137 elif normalized == "modloader": 

138 modloaders: set[str] = set() 

139 for loader in config[section].keys(): 

140 modloaders.update(normalize_modloader(loader)) 

141 match_criteria[normalized] = tuple(sorted(modloaders)) 

142 else: 

143 # really hoping delimiter shenanigans doesn't show up anywhere else 

144 match_criteria[normalized] = tuple(config[section].keys()) 

145 

146 link_folders = match_criteria.pop("link-folders", ()) 

147 do_not_link = match_criteria.pop("do-not-link", _DEFAULT_DO_NOT_LINK) 

148 

149 return cls( 

150 priority, 

151 name, 

152 root, 

153 tuple(match_criteria.items()), 

154 link_folders, 

155 max_link_depth=max_link_depth, 

156 do_not_link=do_not_link, 

157 ) 

158 

159 def write_to_cfg(self, config_file: Path | None = None) -> str: 

160 """Write this box's configuration to INI 

161 

162 Parameters 

163 ---------- 

164 config_file : Path, optional 

165 The path to the config file, assuming you'd like to write the 

166 contents to file 

167 

168 Returns 

169 ------- 

170 str 

171 An INI-syntax rendering of this shulker box's config 

172 

173 Notes 

174 ----- 

175 The "root" attribute is ignored for this method 

176 """ 

177 properties: dict[str, Any] = {"priority": self.priority} 

178 if self.max_link_depth != _DEFAULT_LINK_DEPTH: 

179 properties["max-link-depth"] = self.max_link_depth 

180 

181 config = cfg.dumps( 

182 os.path.join(self.name, fs.SHULKER_BOX_CONFIG_NAME), 

183 properties, 

184 **dict(self.match_criteria), 

185 link_folders=self.link_folders, 

186 do_not_link=self.do_not_link, 

187 ) 

188 

189 if config_file: 

190 config_file.write_text(config) 

191 return config 

192 

193 def matches(self, instance: InstanceSpec) -> bool: 

194 """Determine whether the shulker box matches the given instance 

195 

196 Parameters 

197 ---------- 

198 instance : InstanceSpec 

199 The instance's specification 

200 

201 Returns 

202 ------- 

203 bool 

204 True if the instance matches the shulker box's conditions, False 

205 otherwise. 

206 """ 

207 for condition, values in self.match_criteria: 

208 match condition: # these should have been normalized on read-in 

209 case "instances": 

210 for value in values: 

211 if fnmatch.fnmatchcase(instance.name, value): 

212 break 

213 else: 

214 return False 

215 case "tags": 

216 for value in values: 

217 if value == "*": # in case instance.tags is empty 

218 break 

219 if fnmatch.filter( 

220 [tag.lower() for tag in instance.tags], value.lower() 

221 ): 

222 break 

223 else: 

224 return False 

225 case "modloader": 

226 for value in values: 

227 if fnmatch.fnmatchcase( 

228 instance.modloader.lower(), 

229 value.lower(), 

230 ): 

231 break 

232 else: 

233 return False 

234 case "minecraft": 

235 for value in values: 

236 if any( 

237 ( 

238 _matches_version(value, version) 

239 for version in instance.minecraft_versions 

240 ) 

241 ): 

242 break 

243 else: 

244 return False 

245 case "hosts": 

246 # this is handled at a higher level 

247 pass 

248 case _: 

249 raise NotImplementedError( 

250 f"Don't know how to apply match condition {condition}." 

251 ) 

252 return True 

253 

254 def matches_host(self, hostname: str): 

255 """Determine whether the shulker box should be linked to from the 

256 current host machine 

257 

258 Returns 

259 ------- 

260 bool 

261 True if the shulker box's hosts spec matches the host, False otherwise. 

262 """ 

263 for condition, values in self.match_criteria: 

264 if condition == "hosts": 

265 if not any( 

266 fnmatch.fnmatchcase(hostname.lower(), host_spec.lower()) 

267 for host_spec in values 

268 ): 

269 return False 

270 return True 

271 

272 

273def _matches_version(version_spec: str, version_string: str) -> bool: 

274 """Determine whether a version spec matches a version string, taking into 

275 account that neither users nor Mojang rigidly follow semver (or at least 

276 PEP440) 

277 

278 Parameters 

279 ---------- 

280 version_spec : str 

281 A version specification provided by a user 

282 version_string : str 

283 A version string, likely parsed from an instance's configuration 

284 

285 Returns 

286 ------- 

287 bool 

288 True if the spec matches the version, False otherwise 

289 

290 Notes 

291 ----- 

292 This method *does not* match snapshots to their corresponding version 

293 range--for that you're just going to have to be explicit. 

294 """ 

295 try: 

296 return semver.SimpleSpec(version_spec).match(semver.Version(version_string)) 

297 except ValueError: 

298 # fall back to simple fnmatching 

299 return fnmatch.fnmatchcase(version_string.lower(), version_spec.lower()) 

300 

301 

302def create_shulker_box( 

303 minecraft_root: Path, shulker_box: ShulkerBox, folders: Iterable[str] 

304) -> None: 

305 """Create a shulker box folder based on the provided configuration 

306 

307 Parameters 

308 ---------- 

309 minecraft_root : Path 

310 The root directory that your minecraft stuff (or, at least, the one 

311 that's the parent of your EnderChest folder) 

312 shulker_box : ShulkerBox 

313 The spec of the box to create 

314 folders : list-like of str 

315 The folders to create inside the shulker box (not including link folders) 

316 

317 Notes 

318 ----- 

319 - The "root" attribute of the ShulkerBox config will be ignored--instead 

320 the shulker box will be created at 

321 <minecraft_root>/EnderChest/<shulker box name> 

322 - This method will fail if there is no EnderChest set up in the minecraft 

323 root 

324 - This method does not check to see if there is already a shulker box 

325 set up at the specified location--if one exists, its config will 

326 be overwritten 

327 """ 

328 root = fs.shulker_box_root(minecraft_root, shulker_box.name) 

329 root.mkdir(exist_ok=True) 

330 

331 for folder in (*folders, *shulker_box.link_folders): 

332 CRAFT_LOGGER.debug(f"Creating {root / folder}") 

333 (root / folder).mkdir(exist_ok=True, parents=True) 

334 

335 config_path = fs.shulker_box_config(minecraft_root, shulker_box.name) 

336 shulker_box.write_to_cfg(config_path) 

337 CRAFT_LOGGER.info(f"Shulker box configuration written to {config_path}")