Coverage for enderchest/shulker_box.py: 98%

163 statements  

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

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

2 

3import fnmatch 

4import os 

5import re 

6from collections.abc import Iterable 

7from pathlib import Path 

8from typing import Any, NamedTuple 

9 

10import semantic_version as semver 

11 

12from . import config as cfg 

13from . import filesystem as fs 

14from .instance import InstanceSpec, normalize_modloader 

15from .loggers import CRAFT_LOGGER 

16 

17_DEFAULT_PRIORITY = 0 

18_DEFAULT_LINK_DEPTH = 2 

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

20 

21 

22class ShulkerBox(NamedTuple): 

23 """Specification of a shulker box 

24 

25 Parameters 

26 ---------- 

27 priority : int 

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

29 boxes are linked last) 

30 name : str 

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

32 priority ties) 

33 root : Path 

34 The path to the root of the shulker box 

35 match_criteria : list-like of tuples 

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

37 consists of: 

38 

39 - the name of the condition 

40 - the matching values for that condition 

41 

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

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

44 link_folders : list-like of str 

45 The folders that should be linked in their entirety 

46 max_link_depth : int, optional 

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

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

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

50 linked. This behavior can be overridden by explicitly setting 

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

52 so use it at your own risk. 

53 do_not_link : list-like of str, optional 

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

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

56 

57 Notes 

58 ----- 

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

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

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

62 """ 

63 

64 priority: int 

65 name: str 

66 root: Path 

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

68 link_folders: tuple[str, ...] 

69 max_link_depth: int = _DEFAULT_LINK_DEPTH 

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

71 

72 @classmethod 

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

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

75 

76 Parameters 

77 ---------- 

78 config_file : Path 

79 The path to the config file 

80 

81 Returns 

82 ------- 

83 ShulkerBox 

84 The resulting ShulkerBox 

85 

86 Raises 

87 ------ 

88 ValueError 

89 If the config file at that location cannot be parsed 

90 FileNotFoundError 

91 If there is no config file at the specified location 

92 """ 

93 priority = 0 

94 max_link_depth = 2 

95 root = config_file.parent 

96 name = root.name 

97 config = cfg.read_cfg(config_file) 

98 

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

100 

101 for section in config.sections(): 

102 normalized = ( 

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

104 ) 

105 if normalized.endswith("s"): 

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

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

108 normalized = "link-folders" 

109 if normalized in ("donotlink",): 

110 normalized = "do-not-link" 

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

112 normalized = "minecraft" 

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

114 normalized = "modloader" 

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

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

117 

118 if normalized == "propertie": # lulz 

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

120 # most of this section gets ignored 

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

122 max_link_depth = config[section].getint( 

123 "max-link-depth", _DEFAULT_LINK_DEPTH 

124 ) 

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

126 continue 

127 if normalized in match_criteria: 

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

129 

130 if normalized == "minecraft": 

131 minecraft_versions = [] 

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

133 if value is None: 

134 minecraft_versions.append(key) 

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

136 minecraft_versions.append(value) 

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

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

139 match_criteria[normalized] = tuple(minecraft_versions) 

140 elif normalized == "modloader": 

141 modloaders: set[str] = set() 

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

143 modloaders.update(normalize_modloader(loader)) 

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

145 else: 

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

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

148 

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

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

151 

152 return cls( 

153 priority, 

154 name, 

155 root, 

156 tuple(match_criteria.items()), 

157 link_folders, 

158 max_link_depth=max_link_depth, 

159 do_not_link=do_not_link, 

160 ) 

161 

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

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

164 

165 Parameters 

166 ---------- 

167 config_file : Path, optional 

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

169 contents to file 

170 

171 Returns 

172 ------- 

173 str 

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

175 

176 Notes 

177 ----- 

178 The "root" attribute is ignored for this method 

179 """ 

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

181 if self.max_link_depth != _DEFAULT_LINK_DEPTH: 

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

183 

184 config = cfg.dumps( 

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

186 properties, 

187 **dict(self.match_criteria), 

188 link_folders=self.link_folders, 

189 do_not_link=self.do_not_link, 

190 ) 

191 

192 if config_file: 

193 config_file.write_text(config) 

194 return config 

195 

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

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

198 

199 Parameters 

200 ---------- 

201 instance : InstanceSpec 

202 The instance's specification 

203 

204 Returns 

205 ------- 

206 bool 

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

208 otherwise. 

209 """ 

210 for condition, values in self.match_criteria: 

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

212 case "instances": 

213 matchers = [] 

214 exclusions = [] 

215 for value in values: 

216 if value.startswith("!"): 

217 exclusions.append(value[1:]) 

218 else: 

219 matchers.append(value) 

220 for value in exclusions: 

221 if _matches_string(value, instance.name, case_sensitive=True): 

222 return False 

223 

224 if len(matchers) == 0: # implicit "*" 

225 matchers = ["*"] 

226 

227 for value in matchers: 

228 if _matches_string(value, instance.name, case_sensitive=True): 

229 break 

230 else: 

231 return False 

232 

233 case "tags": 

234 matchers = [] 

235 exclusions = [] 

236 for value in values: 

237 if value.startswith("!"): 

238 exclusions.append(value[1:]) 

239 else: 

240 matchers.append(value) 

241 

242 for value in exclusions: 

243 for tag in instance.tags: 

244 if _matches_string(value, tag): 

245 return False 

246 

247 if len(matchers) == 0: # implicit "*" 

248 matchers = ["*"] 

249 

250 for value in matchers: 

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

252 break 

253 for tag in instance.tags: 

254 if _matches_string(value, tag): 

255 break 

256 else: 

257 continue 

258 break 

259 else: 

260 return False 

261 

262 case "modloader": 

263 for value in values: 

264 if _matches_string(value, instance.modloader): 

265 break 

266 else: 

267 return False 

268 

269 case "minecraft": 

270 for value in values: 

271 if any( 

272 ( 

273 _matches_version(value, version) 

274 for version in instance.minecraft_versions 

275 ) 

276 ): 

277 break 

278 else: 

279 return False 

280 

281 case "hosts": 

282 # this is handled at a higher level 

283 pass 

284 

285 case _: 

286 raise NotImplementedError( 

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

288 ) 

289 return True 

290 

291 def matches_host(self, hostname: str): 

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

293 current host machine 

294 

295 Returns 

296 ------- 

297 bool 

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

299 """ 

300 for condition, values in self.match_criteria: 

301 if condition == "hosts": 

302 if not any( 

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

304 for host_spec in values 

305 ): 

306 return False 

307 return True 

308 

309 

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

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

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

313 PEP440) 

314 

315 Parameters 

316 ---------- 

317 version_spec : str 

318 A version specification provided by a user 

319 version_string : str 

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

321 

322 Returns 

323 ------- 

324 bool 

325 True if the spec matches the version, False otherwise 

326 

327 Notes 

328 ----- 

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

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

331 """ 

332 try: 

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

334 except ValueError: 

335 # fall back to simple fnmatching 

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

337 

338 

339def _matches_string(pattern: str, value: str, case_sensitive: bool = False) -> bool: 

340 """Determine whether the given pattern matches the provided value. 

341 

342 Parameters 

343 ---------- 

344 pattern : str 

345 The pattern to match. This can be a literal value, an fnmatch pattern with wildcards or a regular expression 

346 if quoted and prefixed with an "r". 

347 value : str 

348 The value to check 

349 case_sensitive : bool, optional 

350 Whether the matching is case-sensitive. Default is False. Note that this is **ignored** if the provided pattern 

351 is a regular expression. 

352 

353 Returns 

354 ------- 

355 bool 

356 True if the pattern matches the value, False otherwise 

357 """ 

358 pattern = pattern.strip() 

359 value = value.strip() 

360 if re.match(r"^r('|\").*\1$", pattern): 

361 return re.match(pattern[2:-1], value) is not None 

362 if re.match(r"^('|\").*\1$", pattern): 

363 pattern = pattern[1:-1] 

364 if not case_sensitive: 

365 pattern = pattern.lower() 

366 value = value.lower() 

367 return fnmatch.fnmatchcase(value, pattern) 

368 

369 

370def create_shulker_box( 

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

372) -> None: 

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

374 

375 Parameters 

376 ---------- 

377 minecraft_root : Path 

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

379 that's the parent of your EnderChest folder) 

380 shulker_box : ShulkerBox 

381 The spec of the box to create 

382 folders : list-like of str 

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

384 

385 Notes 

386 ----- 

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

388 the shulker box will be created at 

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

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

391 root 

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

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

394 be overwritten 

395 """ 

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

397 root.mkdir(exist_ok=True) 

398 

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

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

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

402 

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

404 shulker_box.write_to_cfg(config_path) 

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