Coverage for enderchest/shulker_box.py: 98%

162 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-04 01:41 +0000

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

2 

3import fnmatch 

4import os 

5import re 

6from pathlib import Path 

7from typing import Any, Iterable, NamedTuple 

8 

9import semantic_version as semver 

10 

11from . import config as cfg 

12from . import filesystem as fs 

13from .instance import InstanceSpec, normalize_modloader 

14from .loggers import CRAFT_LOGGER 

15 

16_DEFAULT_PRIORITY = 0 

17_DEFAULT_LINK_DEPTH = 2 

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

19 

20 

21class ShulkerBox(NamedTuple): 

22 """Specification of a shulker box 

23 

24 Parameters 

25 ---------- 

26 priority : int 

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

28 boxes are linked last) 

29 name : str 

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

31 priority ties) 

32 root : Path 

33 The path to the root of the shulker box 

34 match_criteria : list-like of tuples 

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

36 consists of: 

37 

38 - the name of the condition 

39 - the matching values for that condition 

40 

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

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

43 link_folders : list-like of str 

44 The folders that should be linked in their entirety 

45 max_link_depth : int, optional 

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

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

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

49 linked. This behavior can be overridden by explicitly setting 

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

51 so use it at your own risk. 

52 do_not_link : list-like of str, optional 

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

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

55 

56 Notes 

57 ----- 

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

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

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

61 """ 

62 

63 priority: int 

64 name: str 

65 root: Path 

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

67 link_folders: tuple[str, ...] 

68 max_link_depth: int = _DEFAULT_LINK_DEPTH 

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

70 

71 @classmethod 

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

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

74 

75 Parameters 

76 ---------- 

77 config_file : Path 

78 The path to the config file 

79 

80 Returns 

81 ------- 

82 ShulkerBox 

83 The resulting ShulkerBox 

84 

85 Raises 

86 ------ 

87 ValueError 

88 If the config file at that location cannot be parsed 

89 FileNotFoundError 

90 If there is no config file at the specified location 

91 """ 

92 priority = 0 

93 max_link_depth = 2 

94 root = config_file.parent 

95 name = root.name 

96 config = cfg.read_cfg(config_file) 

97 

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

99 

100 for section in config.sections(): 

101 normalized = ( 

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

103 ) 

104 if normalized.endswith("s"): 

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

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

107 normalized = "link-folders" 

108 if normalized in ("donotlink",): 

109 normalized = "do-not-link" 

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

111 normalized = "minecraft" 

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

113 normalized = "modloader" 

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

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

116 

117 if normalized == "propertie": # lulz 

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

119 # most of this section gets ignored 

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

121 max_link_depth = config[section].getint( 

122 "max-link-depth", _DEFAULT_LINK_DEPTH 

123 ) 

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

125 continue 

126 if normalized in match_criteria: 

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

128 

129 if normalized == "minecraft": 

130 minecraft_versions = [] 

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

132 if value is None: 

133 minecraft_versions.append(key) 

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

135 minecraft_versions.append(value) 

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

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

138 match_criteria[normalized] = tuple(minecraft_versions) 

139 elif normalized == "modloader": 

140 modloaders: set[str] = set() 

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

142 modloaders.update(normalize_modloader(loader)) 

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

144 else: 

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

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

147 

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

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

150 

151 return cls( 

152 priority, 

153 name, 

154 root, 

155 tuple(match_criteria.items()), 

156 link_folders, 

157 max_link_depth=max_link_depth, 

158 do_not_link=do_not_link, 

159 ) 

160 

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

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

163 

164 Parameters 

165 ---------- 

166 config_file : Path, optional 

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

168 contents to file 

169 

170 Returns 

171 ------- 

172 str 

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

174 

175 Notes 

176 ----- 

177 The "root" attribute is ignored for this method 

178 """ 

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

180 if self.max_link_depth != _DEFAULT_LINK_DEPTH: 

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

182 

183 config = cfg.dumps( 

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

185 properties, 

186 **dict(self.match_criteria), 

187 link_folders=self.link_folders, 

188 do_not_link=self.do_not_link, 

189 ) 

190 

191 if config_file: 

192 config_file.write_text(config) 

193 return config 

194 

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

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

197 

198 Parameters 

199 ---------- 

200 instance : InstanceSpec 

201 The instance's specification 

202 

203 Returns 

204 ------- 

205 bool 

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

207 otherwise. 

208 """ 

209 for condition, values in self.match_criteria: 

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

211 case "instances": 

212 matchers = [] 

213 exclusions = [] 

214 for value in values: 

215 if value.startswith("!"): 

216 exclusions.append(value[1:]) 

217 else: 

218 matchers.append(value) 

219 for value in exclusions: 

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

221 return False 

222 

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

224 matchers = ["*"] 

225 

226 for value in matchers: 

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

228 break 

229 else: 

230 return False 

231 

232 case "tags": 

233 matchers = [] 

234 exclusions = [] 

235 for value in values: 

236 if value.startswith("!"): 

237 exclusions.append(value[1:]) 

238 else: 

239 matchers.append(value) 

240 

241 for value in exclusions: 

242 for tag in instance.tags: 

243 if _matches_string(value, tag): 

244 return False 

245 

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

247 matchers = ["*"] 

248 

249 for value in matchers: 

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

251 break 

252 for tag in instance.tags: 

253 if _matches_string(value, tag): 

254 break 

255 else: 

256 continue 

257 break 

258 else: 

259 return False 

260 

261 case "modloader": 

262 for value in values: 

263 if _matches_string(value, instance.modloader): 

264 break 

265 else: 

266 return False 

267 

268 case "minecraft": 

269 for value in values: 

270 if any( 

271 ( 

272 _matches_version(value, version) 

273 for version in instance.minecraft_versions 

274 ) 

275 ): 

276 break 

277 else: 

278 return False 

279 

280 case "hosts": 

281 # this is handled at a higher level 

282 pass 

283 

284 case _: 

285 raise NotImplementedError( 

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

287 ) 

288 return True 

289 

290 def matches_host(self, hostname: str): 

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

292 current host machine 

293 

294 Returns 

295 ------- 

296 bool 

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

298 """ 

299 for condition, values in self.match_criteria: 

300 if condition == "hosts": 

301 if not any( 

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

303 for host_spec in values 

304 ): 

305 return False 

306 return True 

307 

308 

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

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

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

312 PEP440) 

313 

314 Parameters 

315 ---------- 

316 version_spec : str 

317 A version specification provided by a user 

318 version_string : str 

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

320 

321 Returns 

322 ------- 

323 bool 

324 True if the spec matches the version, False otherwise 

325 

326 Notes 

327 ----- 

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

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

330 """ 

331 try: 

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

333 except ValueError: 

334 # fall back to simple fnmatching 

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

336 

337 

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

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

340 

341 Parameters 

342 ---------- 

343 pattern : str 

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

345 if quoted and prefixed with an "r". 

346 value : str 

347 The value to check 

348 case_sensitive : bool, optional 

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

350 is a regular expression. 

351 

352 Returns 

353 ------- 

354 bool 

355 True if the pattern matches the value, False otherwise 

356 """ 

357 pattern = pattern.strip() 

358 value = value.strip() 

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

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

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

362 pattern = pattern[1:-1] 

363 if not case_sensitive: 

364 pattern = pattern.lower() 

365 value = value.lower() 

366 return fnmatch.fnmatchcase(value, pattern) 

367 

368 

369def create_shulker_box( 

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

371) -> None: 

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

373 

374 Parameters 

375 ---------- 

376 minecraft_root : Path 

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

378 that's the parent of your EnderChest folder) 

379 shulker_box : ShulkerBox 

380 The spec of the box to create 

381 folders : list-like of str 

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

383 

384 Notes 

385 ----- 

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

387 the shulker box will be created at 

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

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

390 root 

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

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

393 be overwritten 

394 """ 

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

396 root.mkdir(exist_ok=True) 

397 

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

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

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

401 

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

403 shulker_box.write_to_cfg(config_path) 

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