Coverage for enderchest/shulker_box.py: 96%

124 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-03 20:14 +0000

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

2import fnmatch 

3import os 

4from pathlib import Path 

5from typing import Any, 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 fnmatch.filter( 

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

219 ): 

220 break 

221 else: 

222 return False 

223 case "modloader": 

224 for value in values: 

225 if fnmatch.fnmatchcase( 

226 instance.modloader.lower(), 

227 value.lower(), 

228 ): 

229 break 

230 else: 

231 return False 

232 case "minecraft": 

233 for value in values: 

234 if any( 

235 ( 

236 _matches_version(value, version) 

237 for version in instance.minecraft_versions 

238 ) 

239 ): 

240 break 

241 else: 

242 return False 

243 case "hosts": 

244 # this is handled at a higher level 

245 pass 

246 case _: 

247 raise NotImplementedError( 

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

249 ) 

250 return True 

251 

252 def matches_host(self, hostname: str): 

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

254 current host machine 

255 

256 Returns 

257 ------- 

258 bool 

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

260 """ 

261 for condition, values in self.match_criteria: 

262 if condition == "hosts": 

263 if not any( 

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

265 for host_spec in values 

266 ): 

267 return False 

268 return True 

269 

270 

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

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

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

274 PEP440) 

275 

276 Parameters 

277 ---------- 

278 version_spec : str 

279 A version specification provided by a user 

280 version_string : str 

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

282 

283 Returns 

284 ------- 

285 bool 

286 True if the spec matches the version, False otherwise 

287 

288 Notes 

289 ----- 

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

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

292 """ 

293 try: 

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

295 except ValueError: 

296 # fall back to simple fnmatching 

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

298 

299 

300DEFAULT_SHULKER_FOLDERS = ( # TODO: customize in enderchest.cfg 

301 "config", 

302 "mods", 

303 "resourcepacks", 

304 "saves", 

305 "shaderpacks", 

306) 

307 

308STANDARD_LINK_FOLDERS = ( # TODO: customize in enderchest.cfg 

309 "backups", 

310 "cachedImages", 

311 "crash-reports", 

312 "logs", 

313 "replay_recordings", 

314 "screenshots", 

315 ".bobby", 

316) 

317 

318 

319def create_shulker_box(minecraft_root: Path, shulker_box: ShulkerBox) -> None: 

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

321 

322 Parameters 

323 ---------- 

324 minecraft_root : Path 

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

326 that's the parent of your EnderChest folder) 

327 shulker_box : ShulkerBox 

328 The spec of the box to create 

329 

330 Notes 

331 ----- 

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

333 the shulker box will be created at 

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

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

336 root 

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

338 set up at the specificed location--if one exists, its config will 

339 be overwritten 

340 """ 

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

342 root.mkdir(exist_ok=True) 

343 

344 for folder in (*DEFAULT_SHULKER_FOLDERS, *shulker_box.link_folders): 

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

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

347 

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

349 shulker_box.write_to_cfg(config_path) 

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