Coverage for enderchest/enderchest.py: 99%

165 statements  

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

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

2from dataclasses import dataclass 

3from pathlib import Path 

4from socket import gethostname 

5from typing import Any, Iterable 

6from urllib.parse import ParseResult, urlparse 

7 

8from . import config as cfg 

9from . import filesystem as fs 

10from . import instance as i 

11from . import sync 

12from .loggers import CRAFT_LOGGER, GATHER_LOGGER 

13from .sync import abspath_from_uri 

14 

15 

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

17class EnderChest: 

18 """Configuration of an EnderChest 

19 

20 Parameters 

21 ---------- 

22 uri : URI or Path 

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

24 EnderChest installations, including both the path to where 

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

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

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

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

29 constructor will try to figure out the rest. 

30 name : str, optional 

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

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

33 instances : list-like of InstanceSpec, optional 

34 The list of instances to register with this EnderChest installation 

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

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

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

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

39 

40 Attributes 

41 ---------- 

42 name : str 

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

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

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

46 backup or for multi-user systems). 

47 uri : str 

48 The complete URI of this instance 

49 root : Path 

50 The path to this EnderChest folder 

51 instances : list-like of InstanceSpec 

52 The instances registered with this EnderChest 

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

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

55 with their aliases 

56 offer_to_update_symlink_allowlist : bool 

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

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

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

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

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

62 parameter to False. 

63 sync_confirm_wait : bool or int 

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

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

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

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

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

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

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

71 when actually calling the sync commands. 

72 place_after_open: bool 

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

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

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

76 do_not_sync : list of str 

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

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

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

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

81 """ 

82 

83 name: str 

84 _uri: ParseResult 

85 _instances: list[i.InstanceSpec] 

86 _remotes: dict[str, ParseResult] 

87 offer_to_update_symlink_allowlist: bool = True 

88 sync_confirm_wait: bool | int = 5 

89 place_after_open: bool = True 

90 do_not_sync = ["EnderChest/enderchest.cfg", "EnderChest/.*", ".DS_Store"] 

91 

92 def __init__( 

93 self, 

94 uri: str | ParseResult | Path, 

95 name: str | None = None, 

96 remotes: Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]] 

97 | None = None, 

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

99 ): 

100 try: 

101 if isinstance(uri, ParseResult): 

102 self._uri = uri 

103 elif isinstance(uri, Path): 

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

105 else: 

106 self._uri = urlparse(uri) 

107 except AttributeError as parse_problem: # pragma: no cover 

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

109 

110 if not self._uri.netloc: 

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

112 if not self._uri.scheme: 

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

114 

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

116 

117 self._instances = [] 

118 self._remotes = {} 

119 

120 for instance in instances or (): 

121 self.register_instance(instance) 

122 

123 for remote in remotes or (): 

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

125 self.register_remote(remote) 

126 else: 

127 self.register_remote(*remote) 

128 

129 @property 

130 def uri(self) -> str: 

131 return self._uri.geturl() 

132 

133 def __repr__(self) -> str: 

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

135 

136 @property 

137 def root(self) -> Path: 

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

139 

140 @property 

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

142 return tuple(self._instances) 

143 

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

145 """Register a new Minecraft installation 

146 

147 Parameters 

148 ---------- 

149 instance : InstanceSpec 

150 The instance to register 

151 

152 Returns 

153 ------- 

154 InstanceSpec 

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

156 name changed or somesuch) 

157 

158 Notes 

159 ----- 

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

161 this method will choose a new one 

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

163 replace that instance 

164 """ 

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

166 for old_instance in self._instances: 

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

168 matching_instances.append(old_instance) 

169 self._instances.remove(old_instance) 

170 

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

172 

173 name = instance.name 

174 counter = 0 

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

176 while True: 

177 if name not in taken_names: 

178 break 

179 counter += 1 

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

181 

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

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

184 return self._instances[-1] 

185 

186 @property 

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

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

189 

190 def register_remote( 

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

192 ) -> None: 

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

194 registry) 

195 

196 Parameters 

197 ---------- 

198 remote : URI 

199 The URI of the remote 

200 alias : str, optional 

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

202 will be used. 

203 

204 Raises 

205 ------ 

206 ValueError 

207 If the provided remote is invalid 

208 """ 

209 try: 

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

211 alias = alias or remote.hostname 

212 if not alias: # pragma: no cover 

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

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

215 self._remotes[alias] = remote 

216 except AttributeError as parse_problem: # pragma: no cover 

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

218 

219 @classmethod 

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

221 """Parse an EnderChest from its config file 

222 

223 Parameters 

224 ---------- 

225 config_file : Path 

226 The path to the config file 

227 

228 Returns 

229 ------- 

230 EnderChest 

231 The resulting EnderChest 

232 

233 Raises 

234 ------ 

235 ValueError 

236 If the config file at that location cannot be parsed 

237 FileNotFoundError 

238 If there is no config file at the specified location 

239 """ 

240 GATHER_LOGGER.debug("Reading config file from %s", config_file) 

241 config = cfg.read_cfg(config_file) 

242 

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

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

245 

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

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

248 

249 requires_rewrite = False 

250 

251 scheme: str | None = None 

252 netloc: str | None = None 

253 name: str | None = None 

254 sync_confirm_wait: str | None = None 

255 place_after_open: bool | None = None 

256 offer_to_update_symlink_allowlist: bool = True 

257 do_not_sync: list[str] | None = None 

258 

259 for section in config.sections(): 

260 if section == "properties": 

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

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

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

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

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

266 offer_to_update_symlink_allowlist = config[section].getboolean( 

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

268 ) 

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

270 do_not_sync = cfg.parse_ini_list( 

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

272 ) 

273 elif section == "remotes": 

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

275 if remote[1] is None: 

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

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

278 else: 

279 # TODO: flag requires_rewrite if instance was normalized 

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

281 

282 scheme = scheme or sync.DEFAULT_PROTOCOL 

283 netloc = netloc or sync.get_default_netloc() 

284 uri = ParseResult( 

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

286 ) 

287 

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

289 if sync_confirm_wait is not None: 

290 match sync_confirm_wait.lower(): 

291 case "true" | "prompt" | "yes": 

292 ender_chest.sync_confirm_wait = True 

293 case "false" | "no" | "skip": 

294 ender_chest.sync_confirm_wait = False 

295 case _: 

296 try: 

297 ender_chest.sync_confirm_wait = int(sync_confirm_wait) 

298 except ValueError as bad_input: 

299 raise ValueError( 

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

301 f" {sync_confirm_wait}" 

302 ) from bad_input 

303 if place_after_open is None: 

304 GATHER_LOGGER.warning( 

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

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

307 "\nedit the value in %s", 

308 config_file, 

309 ) 

310 ender_chest.place_after_open = False 

311 requires_rewrite = True 

312 else: 

313 ender_chest.place_after_open = place_after_open 

314 

315 ender_chest.offer_to_update_symlink_allowlist = ( 

316 offer_to_update_symlink_allowlist 

317 ) 

318 

319 if do_not_sync is not None: 

320 ender_chest.do_not_sync = do_not_sync 

321 chest_cfg_exclusion = "/".join( 

322 (fs.ENDER_CHEST_FOLDER_NAME, fs.ENDER_CHEST_CONFIG_NAME) 

323 ) 

324 if chest_cfg_exclusion not in do_not_sync: 

325 GATHER_LOGGER.warning( 

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

327 " config file from sync operations." 

328 "\nThat is being fixed now." 

329 ) 

330 ender_chest.do_not_sync.insert(0, chest_cfg_exclusion) 

331 requires_rewrite = True 

332 

333 if requires_rewrite: 

334 ender_chest.write_to_cfg(config_file) 

335 return cls.from_cfg(config_file) 

336 return ender_chest 

337 

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

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

340 

341 Parameters 

342 ---------- 

343 config_file : Path, optional 

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

345 contents to file 

346 

347 Returns 

348 ------- 

349 str 

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

351 

352 Notes 

353 ----- 

354 The "root" attribute is ignored for this method 

355 """ 

356 properties: dict[str, Any] = { 

357 "name": self.name, 

358 "address": self._uri.netloc, 

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

360 } 

361 if self.sync_confirm_wait is True: 

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

363 else: 

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

365 

366 properties["place-after-open"] = self.place_after_open 

367 properties[ 

368 "offer-to-update-symlink-allowlist" 

369 ] = self.offer_to_update_symlink_allowlist 

370 

371 properties["do-not-sync"] = self.do_not_sync 

372 

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

374 

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

376 

377 for instance in self.instances: 

378 instances[instance.name] = { 

379 "root": instance.root, 

380 "minecraft-version": instance.minecraft_versions, 

381 "modloader": instance.modloader, 

382 "groups": instance.groups_, 

383 "tags": instance.tags_, 

384 } 

385 

386 config = cfg.dumps( 

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

388 ) 

389 

390 if config_file: 

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

392 config_file.write_text(config) 

393 return config 

394 

395 

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

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

398 

399 Parameters 

400 ---------- 

401 minecraft_root : Path 

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

403 one inside which you want to create your EnderChest) 

404 ender_chest : EnderChest 

405 The spec of the chest to create 

406 

407 Notes 

408 ----- 

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

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

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

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

413 be overwritten 

414 """ 

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

416 root.mkdir(exist_ok=True) 

417 

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

419 ender_chest.write_to_cfg(config_path) 

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