Coverage for enderchest/enderchest.py: 99%

182 statements  

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

1"""Specification and configuration of an EnderChest""" 

2 

3from dataclasses import dataclass 

4from pathlib import Path 

5from socket import gethostname 

6from typing import Any, Iterable 

7from urllib.parse import ParseResult, urlparse 

8 

9from . import config as cfg 

10from . import filesystem as fs 

11from . import instance as i 

12from . import sync 

13from .loggers import CRAFT_LOGGER, GATHER_LOGGER, INVENTORY_LOGGER 

14from .sync import abspath_from_uri 

15 

16_DEFAULTS = ( 

17 ("offer_to_update_symlink_allowlist", True), 

18 ("sync_confirm_wait", 5), 

19 ("place_after_open", True), 

20 ("do_not_sync", ("EnderChest/enderchest.cfg", "EnderChest/.*", ".DS_Store")), 

21 ( 

22 "shulker_box_folders", 

23 ( 

24 "config", 

25 "mods", 

26 "resourcepacks", 

27 "saves", 

28 "shaderpacks", 

29 ), 

30 ), 

31 ("standard_link_folders", ()), 

32 ( 

33 "global_link_folders", 

34 ( 

35 "backups", 

36 "cachedImages", 

37 "crash-reports", 

38 "logs", 

39 "replay_recordings", 

40 "screenshots", 

41 "schematics", 

42 "config/litematica", # still worth having in case max_depth>2 

43 ".bobby", 

44 ), 

45 ), 

46) 

47 

48 

49@dataclass(init=False, repr=False, eq=False) 

50class EnderChest: 

51 """Configuration of an EnderChest 

52 

53 Parameters 

54 ---------- 

55 uri : URI or Path 

56 The "address" of this EnderChest, ideally as it can be accessed from other 

57 EnderChest installations, including both the path to where 

58 the EnderChest folder can be found (that is, the parent of the 

59 EnderChest folder itself, aka the "minecraft_root"), its net location 

60 including credentials, and the protocol that should be used to perform 

61 the syncing. All that being said, if just a path is provided, the 

62 constructor will try to figure out the rest. 

63 name : str, optional 

64 A unique name to give to this EnderChest installation. If None is 

65 provided, this will be taken from the hostname of the supplied URI. 

66 instances : list-like of InstanceSpec, optional 

67 The list of instances to register with this EnderChest installation 

68 remotes : list-like of URI, or (URI, str) tuples 

69 A list of other installations that this EnderChest should be aware of 

70 (for syncing purposes). When a (URI, str) tuple is provided, the 

71 second value will be used as the name/alias of the remote. 

72 

73 Attributes 

74 ---------- 

75 name : str 

76 The unique name of this EnderChest installation. This is most commonly 

77 the computer's hostname, but one can configure multiple EnderChests 

78 to coexist on the same system (either for the sake of having a "cold" 

79 backup or for multi-user systems). 

80 uri : str 

81 The complete URI of this instance 

82 root : Path 

83 The path to this EnderChest folder 

84 instances : list-like of InstanceSpec 

85 The instances registered with this EnderChest 

86 remotes : list-like of (ParseResult, str) pairs 

87 The other EnderChest installations this EnderChest is aware of, paired 

88 with their aliases 

89 offer_to_update_symlink_allowlist : bool 

90 By default, EnderChest will offer to create or update `allowed_symlinks.txt` 

91 on any 1.20+ instances that do not already blanket allow links into 

92 EnderChest. **EnderChest will never modify that or any other Minecraft 

93 file without your express consent.** If you would prefer to edit these 

94 files yourself (or simply not symlink your world saves), change this 

95 parameter to False. 

96 sync_confirm_wait : bool or int 

97 The default behavior when syncing EnderChests is to first perform a dry 

98 run of every sync operation and then wait 5 seconds before proceeding with the 

99 real sync. The idea is to give the user time to interrupt the sync if 

100 the dry run looks wrong. This can be changed by either raising or lowering 

101 the value of confirm, by disabling the dry-run-first behavior entirely 

102 (`confirm=False`) or by requiring that the user explicitly confirms 

103 the sync (`confirm=True`). This default behavior can also be overridden 

104 when actually calling the sync commands. 

105 place_after_open: bool 

106 By default, EnderChest will follow up any `enderchest open` operation 

107 with an `enderchest place` to refresh any changed symlinks. This 

108 functionality can be disabled by setting this parameter to False. 

109 do_not_sync : list of str 

110 Glob patterns of files that should not be synced between EnderChest 

111 installations. By default, this list comprises `EnderChest/enderchest.cfg`, 

112 any top-level folders starting with a "." (like .git) and 

113 `.DS_Store` (for all you mac gamers). 

114 shulker_box_folders : list of str 

115 The folders that will be created inside each new shulker box 

116 standard_link_folders : list of str 

117 The default set of "link folders" when crafting a new shulker box 

118 global_link_folders : list of str 

119 The "global" set of "link folders," offered as a suggestion when 

120 crafting a new shulker box 

121 """ 

122 

123 name: str 

124 _uri: ParseResult 

125 _instances: list[i.InstanceSpec] 

126 _remotes: dict[str, ParseResult] 

127 offer_to_update_symlink_allowlist: bool 

128 sync_confirm_wait: bool | int 

129 place_after_open: bool 

130 do_not_sync: list[str] 

131 shulker_box_folders: list[str] 

132 standard_link_folders: list[str] 

133 global_link_folders: list[str] 

134 

135 def __init__( 

136 self, 

137 uri: str | ParseResult | Path, 

138 name: str | None = None, 

139 remotes: ( 

140 Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] 

141 | None 

142 ) = None, 

143 instances: Iterable[i.InstanceSpec] | None = None, 

144 ): 

145 for setting, value in _DEFAULTS: 

146 setattr(self, setting, list(value) if isinstance(value, tuple) else value) 

147 

148 try: 

149 if isinstance(uri, ParseResult): 

150 self._uri = uri 

151 elif isinstance(uri, Path): 

152 self._uri = urlparse(uri.absolute().as_uri()) 

153 else: 

154 self._uri = urlparse(uri) 

155 except AttributeError as parse_problem: # pragma: no cover 

156 raise ValueError(f"{uri} is not a valid URI") from parse_problem 

157 

158 if not self._uri.netloc: 

159 self._uri = self._uri._replace(netloc=sync.get_default_netloc()) 

160 if not self._uri.scheme: 

161 self._uri = self._uri._replace(scheme=sync.DEFAULT_PROTOCOL) 

162 

163 self.name = name or self._uri.hostname or gethostname() 

164 

165 self._instances = [] 

166 self._remotes = {} 

167 

168 for instance in instances or (): 

169 self.register_instance(instance) 

170 

171 for remote in remotes or (): 

172 if isinstance(remote, (str, ParseResult)): 

173 self.register_remote(remote) 

174 else: 

175 self.register_remote(*remote) 

176 

177 @property 

178 def uri(self) -> str: 

179 return self._uri.geturl() 

180 

181 def __repr__(self) -> str: 

182 return f"EnderChest({self.uri, self.name})" 

183 

184 @property 

185 def root(self) -> Path: 

186 return fs.ender_chest_folder(abspath_from_uri(self._uri), check_exists=False) 

187 

188 @property 

189 def instances(self) -> tuple[i.InstanceSpec, ...]: 

190 return tuple(self._instances) 

191 

192 def register_instance(self, instance: i.InstanceSpec) -> i.InstanceSpec: 

193 """Register a new Minecraft installation 

194 

195 Parameters 

196 ---------- 

197 instance : InstanceSpec 

198 The instance to register 

199 

200 Returns 

201 ------- 

202 InstanceSpec 

203 The spec of the instance as it was actually registered (in case the 

204 name changed or somesuch) 

205 

206 Notes 

207 ----- 

208 - If the instance's name is already assigned to a registered instance, 

209 this method will choose a new one 

210 - If this instance shares a path with an existing instance, it will 

211 replace that instance 

212 """ 

213 matching_instances: list[i.InstanceSpec] = [] 

214 for old_instance in self._instances: 

215 if i.equals(abspath_from_uri(self._uri), instance, old_instance): 

216 matching_instances.append(old_instance) 

217 self._instances.remove(old_instance) 

218 

219 instance = i.merge(*matching_instances, instance) 

220 

221 name = instance.name 

222 counter = 0 

223 taken_names = {old_instance.name for old_instance in self._instances} 

224 while True: 

225 if name not in taken_names: 

226 break 

227 counter += 1 

228 name = f"{instance.name}.{counter}" 

229 

230 GATHER_LOGGER.debug(f"Registering instance {name} at {instance.root}") 

231 self._instances.append(instance._replace(name=name)) 

232 return self._instances[-1] 

233 

234 @property 

235 def remotes(self) -> tuple[tuple[ParseResult, str], ...]: 

236 return tuple((remote, alias) for alias, remote in self._remotes.items()) 

237 

238 def register_remote( 

239 self, remote: str | ParseResult, alias: str | None = None 

240 ) -> None: 

241 """Register a new remote EnderChest installation (or update an existing 

242 registry) 

243 

244 Parameters 

245 ---------- 

246 remote : URI 

247 The URI of the remote 

248 alias : str, optional 

249 an alias to give to this remote. If None is provided, the URI's hostname 

250 will be used. 

251 

252 Raises 

253 ------ 

254 ValueError 

255 If the provided remote is invalid 

256 """ 

257 try: 

258 remote = remote if isinstance(remote, ParseResult) else urlparse(remote) 

259 alias = alias or remote.hostname 

260 if not alias: # pragma: no cover 

261 raise AttributeError(f"{remote.geturl()} has no hostname") 

262 GATHER_LOGGER.debug("Registering remote %s (%s)", remote.geturl(), alias) 

263 self._remotes[alias] = remote 

264 except AttributeError as parse_problem: # pragma: no cover 

265 raise ValueError(f"{remote} is not a valid URI") from parse_problem 

266 

267 @classmethod 

268 def from_cfg(cls, config_file: Path) -> "EnderChest": 

269 """Parse an EnderChest from its config file 

270 

271 Parameters 

272 ---------- 

273 config_file : Path 

274 The path to the config file 

275 

276 Returns 

277 ------- 

278 EnderChest 

279 The resulting EnderChest 

280 

281 Raises 

282 ------ 

283 ValueError 

284 If the config file at that location cannot be parsed 

285 FileNotFoundError 

286 If there is no config file at the specified location 

287 """ 

288 INVENTORY_LOGGER.debug("Reading config file from %s", config_file) 

289 config = cfg.read_cfg(config_file) 

290 

291 # All I'm gonna say is that Windows pathing is the worst 

292 path = urlparse(config_file.absolute().parent.parent.as_uri()).path 

293 

294 instances: list[i.InstanceSpec] = [] 

295 remotes: list[str | tuple[str, str]] = [] 

296 

297 requires_rewrite = False 

298 

299 scheme: str | None = None 

300 netloc: str | None = None 

301 name: str | None = None 

302 sync_confirm_wait: str | None = None 

303 place_after_open: bool | None = None 

304 offer_to_update_symlink_allowlist: bool = True 

305 do_not_sync: list[str] | None = None 

306 folder_defaults: dict[str, list[str] | None] = { 

307 "shulker_box_folders": None, 

308 "standard_link_folders": None, 

309 "global_link_folders": None, 

310 } 

311 

312 for section in config.sections(): 

313 if section == "properties": 

314 scheme = config[section].get("sync-protocol") 

315 netloc = config[section].get("address") 

316 name = config[section].get("name") 

317 sync_confirm_wait = config[section].get("sync-confirmation-time") 

318 place_after_open = config[section].getboolean("place-after-open") 

319 offer_to_update_symlink_allowlist = config[section].getboolean( 

320 "offer-to-update-symlink-allowlist", True 

321 ) 

322 if "do-not-sync" in config[section].keys(): 

323 do_not_sync = cfg.parse_ini_list( 

324 config[section]["do-not-sync"] or "" 

325 ) 

326 for setting in folder_defaults.keys(): 

327 setting_key = setting.replace("_", "-") 

328 if setting_key in config[section].keys(): 

329 folder_defaults[setting] = cfg.parse_ini_list( 

330 config[section][setting_key] or "" 

331 ) 

332 elif section == "remotes": 

333 for remote in config[section].items(): 

334 if remote[1] is None: 

335 raise ValueError("All remotes must have an alias specified") 

336 remotes.append((remote[1], remote[0])) 

337 else: 

338 # TODO: flag requires_rewrite if instance was normalized 

339 instances.append(i.InstanceSpec.from_cfg(config[section])) 

340 

341 scheme = scheme or sync.DEFAULT_PROTOCOL 

342 netloc = netloc or sync.get_default_netloc() 

343 uri = ParseResult( 

344 scheme=scheme, netloc=netloc, path=path, params="", query="", fragment="" 

345 ) 

346 

347 ender_chest = EnderChest(uri, name, remotes, instances) 

348 if sync_confirm_wait is not None: 

349 match sync_confirm_wait.lower(): 

350 case "true" | "prompt" | "yes" | "confirm": 

351 ender_chest.sync_confirm_wait = True 

352 case "false" | "no" | "skip": 

353 ender_chest.sync_confirm_wait = False 

354 case _: 

355 try: 

356 ender_chest.sync_confirm_wait = int(sync_confirm_wait) 

357 except ValueError as bad_input: 

358 raise ValueError( 

359 "Invalid value for sync-confirmation-time:" 

360 f" {sync_confirm_wait}" 

361 ) from bad_input 

362 if place_after_open is None: 

363 INVENTORY_LOGGER.warning( 

364 "This EnderChest does not have a value set for place-after-open." 

365 "\nIt is being set to False for now. To enable this functionality," 

366 "\nedit the value in %s", 

367 config_file, 

368 ) 

369 ender_chest.place_after_open = False 

370 requires_rewrite = True 

371 else: 

372 ender_chest.place_after_open = place_after_open 

373 

374 ender_chest.offer_to_update_symlink_allowlist = ( 

375 offer_to_update_symlink_allowlist 

376 ) 

377 

378 if do_not_sync is not None: 

379 ender_chest.do_not_sync = do_not_sync 

380 chest_cfg_exclusion = "/".join( 

381 (fs.ENDER_CHEST_FOLDER_NAME, fs.ENDER_CHEST_CONFIG_NAME) 

382 ) 

383 if chest_cfg_exclusion not in do_not_sync: 

384 INVENTORY_LOGGER.warning( 

385 "This EnderChest was not configured to exclude the EnderChest" 

386 " config file from sync operations." 

387 "\nThat is being fixed now." 

388 ) 

389 ender_chest.do_not_sync.insert(0, chest_cfg_exclusion) 

390 requires_rewrite = True 

391 for setting in folder_defaults.keys(): 

392 if folder_defaults[setting] is None: 

393 folder_defaults[setting] = dict(_DEFAULTS)[setting] # type: ignore 

394 # requires_rewrite = True # though I'm considering it 

395 setattr(ender_chest, setting, folder_defaults[setting]) 

396 

397 if requires_rewrite: 

398 ender_chest.write_to_cfg(config_file) 

399 return cls.from_cfg(config_file) 

400 return ender_chest 

401 

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

403 """Write this EnderChest's configuration to INI 

404 

405 Parameters 

406 ---------- 

407 config_file : Path, optional 

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

409 contents to file 

410 

411 Returns 

412 ------- 

413 str 

414 An INI-syntax rendering of this EnderChest's config 

415 

416 Notes 

417 ----- 

418 The "root" attribute is ignored for this method 

419 """ 

420 properties: dict[str, Any] = { 

421 "name": self.name, 

422 "address": self._uri.netloc, 

423 "sync-protocol": self._uri.scheme, 

424 } 

425 if self.sync_confirm_wait is True: 

426 properties["sync-confirmation-time"] = "prompt" 

427 else: 

428 properties["sync-confirmation-time"] = self.sync_confirm_wait 

429 

430 for setting, _ in _DEFAULTS: 

431 if setting == "sync_confirm_wait": 

432 continue # already did this one 

433 setting_key = setting.replace("_", "-") 

434 properties[setting_key] = getattr(self, setting) 

435 

436 remotes: dict[str, str] = {name: uri.geturl() for uri, name in self.remotes} 

437 

438 instances: dict[str, dict[str, Any]] = {} 

439 

440 for instance in self.instances: 

441 instances[instance.name] = { 

442 "root": instance.root, 

443 "minecraft-version": instance.minecraft_versions, 

444 "modloader": instance.modloader, 

445 "groups": instance.groups_, 

446 "tags": instance.tags_, 

447 } 

448 

449 config = cfg.dumps( 

450 fs.ENDER_CHEST_CONFIG_NAME, properties, remotes=remotes, **instances 

451 ) 

452 

453 if config_file: 

454 CRAFT_LOGGER.debug("Writing configuration file to %s", config_file) 

455 config_file.write_text(config) 

456 return config 

457 

458 

459def create_ender_chest(minecraft_root: Path, ender_chest: EnderChest) -> None: 

460 """Create an EnderChest based on the provided configuration 

461 

462 Parameters 

463 ---------- 

464 minecraft_root : Path 

465 The root directory that your minecraft stuff is in (or, at least, the 

466 one inside which you want to create your EnderChest) 

467 ender_chest : EnderChest 

468 The spec of the chest to create 

469 

470 Notes 

471 ----- 

472 - The "root" attribute of the EnderChest config will be ignored--instead 

473 the EnderChest will be created at <minecraft_root>/EnderChest 

474 - This method does not check to see if there is already an EnderChest set 

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

476 be overwritten 

477 """ 

478 root = fs.ender_chest_folder(minecraft_root, check_exists=False) 

479 root.mkdir(exist_ok=True) 

480 

481 config_path = fs.ender_chest_config(minecraft_root, check_exists=False) 

482 ender_chest.write_to_cfg(config_path) 

483 CRAFT_LOGGER.info(f"EnderChest configuration written to {config_path}")