Coverage for enderchest/inventory.py: 82%

117 statements  

« prev     ^ index     » next       coverage.py v7.10.1, created at 2025-07-30 12:06 +0000

1"""Functionality for resolving EnderChest and shulker box states""" 

2 

3import logging 

4from collections.abc import Iterable, Sequence 

5from pathlib import Path 

6from typing import Any 

7from urllib.parse import ParseResult 

8 

9from enderchest.sync import render_remote 

10 

11from . import EnderChest, InstanceSpec, ShulkerBox 

12from . import filesystem as fs 

13from .loggers import INVENTORY_LOGGER 

14 

15 

16def load_ender_chest(minecraft_root: Path) -> EnderChest: 

17 """Load the configuration from the enderchest.cfg file in the EnderChest 

18 folder. 

19 

20 Parameters 

21 ---------- 

22 minecraft_root : Path 

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

24 that's the parent of your EnderChest folder) 

25 

26 Returns 

27 ------- 

28 EnderChest 

29 The EnderChest configuration 

30 

31 Raises 

32 ------ 

33 FileNotFoundError 

34 If no EnderChest folder exists in the given minecraft root or if no 

35 enderchest.cfg file exists within that EnderChest folder 

36 ValueError 

37 If the EnderChest configuration is invalid and could not be parsed 

38 """ 

39 config_path = fs.ender_chest_config(minecraft_root) 

40 INVENTORY_LOGGER.debug("Loading %s", config_path) 

41 ender_chest = EnderChest.from_cfg(config_path) 

42 INVENTORY_LOGGER.debug("Parsed EnderChest installation from %s", minecraft_root) 

43 return ender_chest 

44 

45 

46def load_ender_chest_instances( 

47 minecraft_root: Path, log_level: int = logging.INFO 

48) -> Sequence[InstanceSpec]: 

49 """Get the list of instances registered with the EnderChest located in the 

50 minecraft root 

51 

52 Parameters 

53 ---------- 

54 minecraft_root : Path 

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

56 that's the parent of your EnderChest folder) 

57 log_level : int, optional 

58 By default, this method will report out the minecraft instances it 

59 finds at the INFO level. You can optionally pass in a lower (or higher) 

60 level if this method is being called from another method where that 

61 information is redundant or overly verbose. 

62 

63 Returns 

64 ------- 

65 list of InstanceSpec 

66 The instances registered with the EnderChest 

67 

68 Notes 

69 ----- 

70 If no EnderChest is installed in the given location, then this will return 

71 an empty list rather than failing outright. 

72 """ 

73 try: 

74 ender_chest = load_ender_chest(minecraft_root) 

75 instances: Sequence[InstanceSpec] = ender_chest.instances 

76 except (FileNotFoundError, ValueError) as bad_chest: 

77 INVENTORY_LOGGER.error( 

78 "Could not load EnderChest from %s:\n %s", minecraft_root, bad_chest 

79 ) 

80 instances = [] 

81 if len(instances) == 0: 

82 INVENTORY_LOGGER.warning( 

83 "There are no instances registered to the %s EnderChest", minecraft_root 

84 ) 

85 else: 

86 INVENTORY_LOGGER.log( 

87 log_level, 

88 "These are the instances that are currently registered" 

89 " to the %s EnderChest:\n%s", 

90 minecraft_root, 

91 "\n".join( 

92 [ 

93 f" {i + 1}. {render_instance(instance)}" 

94 for i, instance in enumerate(instances) 

95 ] 

96 ), 

97 ) 

98 return instances 

99 

100 

101def render_instance(instance: InstanceSpec) -> str: 

102 """Render an instance spec to a descriptive string 

103 

104 Parameters 

105 ---------- 

106 instance : InstanceSpec 

107 The instance spec to render 

108 

109 Returns 

110 ------- 

111 str 

112 {instance.name} ({instance.root}) 

113 """ 

114 return f"{instance.name} ({instance.root})" 

115 

116 

117def load_shulker_boxes( 

118 minecraft_root: Path, log_level: int = logging.INFO 

119) -> list[ShulkerBox]: 

120 """Load all shulker boxes in the EnderChest folder and return them in the 

121 order in which they should be linked. 

122 

123 Parameters 

124 ---------- 

125 minecraft_root : Path 

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

127 that's the parent of your EnderChest folder) 

128 log_level : int, optional 

129 By default, this method will report out the minecraft instances it 

130 finds at the INFO level. You can optionally pass in a lower (or higher) 

131 level if this method is being called from another method where that 

132 information is redundant or overly verbose. 

133 

134 Returns 

135 ------- 

136 list of ShulkerBoxes 

137 The shulker boxes found in the EnderChest folder, ordered in terms of 

138 the sequence in which they should be linked 

139 

140 Notes 

141 ----- 

142 If no EnderChest is installed in the given location, then this will return 

143 an empty list rather than failing outright. 

144 """ 

145 shulker_boxes: list[ShulkerBox] = [] 

146 

147 try: 

148 for shulker_config in fs.shulker_box_configs(minecraft_root): 

149 try: 

150 shulker_boxes.append(_load_shulker_box(shulker_config)) 

151 except (FileNotFoundError, ValueError) as bad_shulker: 

152 INVENTORY_LOGGER.warning( 

153 "%s\n Skipping shulker box %s", 

154 bad_shulker, 

155 shulker_config.parent.name, 

156 ) 

157 

158 except FileNotFoundError: 

159 INVENTORY_LOGGER.error( 

160 "There is no EnderChest installed within %s", 

161 minecraft_root, 

162 ) 

163 return [] 

164 

165 shulker_boxes = sorted(shulker_boxes) 

166 

167 if len(shulker_boxes) == 0: 

168 if log_level >= logging.INFO: 

169 INVENTORY_LOGGER.warning( 

170 "There are no shulker boxes within the %s EnderChest", minecraft_root 

171 ) 

172 else: 

173 report_shulker_boxes( 

174 shulker_boxes, log_level, f"the {minecraft_root} EnderChest" 

175 ) 

176 return shulker_boxes 

177 

178 

179def report_shulker_boxes( 

180 shulker_boxes: Iterable[ShulkerBox], log_level: int, ender_chest_name: str 

181) -> None: 

182 """Log the list of shulker boxes in the order they'll be linked 

183 

184 Parameters 

185 ---------- 

186 shulker_boxes : list of ShulkerBoxes 

187 The shulker boxes to report on 

188 log_level : int 

189 The log level of the report 

190 ender_chest_name : str 

191 Which chest is this? 

192 

193 Returns 

194 ------- 

195 None 

196 """ 

197 # TODO: properly log minecraft_root when one appears in the message 

198 INVENTORY_LOGGER.log( 

199 log_level, 

200 f"These are the shulker boxes within {ender_chest_name}" 

201 "\nlisted in the order in which they are linked:\n%s", 

202 "\n".join( 

203 f" {shulker_box.priority}. {_render_shulker_box(shulker_box)}" 

204 for shulker_box in shulker_boxes 

205 ), 

206 ) 

207 

208 

209def _load_shulker_box(config_file: Path) -> ShulkerBox: 

210 """Attempt to load a shulker box from a config file, and if you can't, 

211 at least log why the loading failed. 

212 

213 Parameters 

214 ---------- 

215 config_file : Path 

216 Path to the config file 

217 

218 Returns 

219 ------- 

220 ShulkerBox | None 

221 The parsed shulker box or None, if the shulker box couldn't be parsed 

222 

223 Raises 

224 ------ 

225 FileNotFoundError 

226 If the given config file could not be found 

227 ValueError 

228 If there was a problem parsing the config file 

229 """ 

230 INVENTORY_LOGGER.debug("Attempting to parse %s", config_file) 

231 shulker_box = ShulkerBox.from_cfg(config_file) 

232 INVENTORY_LOGGER.debug("Successfully parsed %s", _render_shulker_box(shulker_box)) 

233 return shulker_box 

234 

235 

236def _render_shulker_box(shulker_box: ShulkerBox) -> str: 

237 """Render a shulker box to a descriptive string 

238 

239 Parameters 

240 ---------- 

241 shulker_box : ShulkerBox 

242 The shulker box spec to render 

243 

244 Returns 

245 ------- 

246 str 

247 {priority}. {folder_name} [({name})] 

248 (if different from folder name) 

249 """ 

250 stringified = f"{shulker_box.root.name}" 

251 if shulker_box.root.name != shulker_box.name: # pragma: no cover 

252 # note: this is not a thing 

253 stringified += f" ({shulker_box.name})" 

254 return stringified 

255 

256 

257def load_ender_chest_remotes( 

258 minecraft_root: Path, log_level: int = logging.INFO 

259) -> list[tuple[ParseResult, str]]: 

260 """Load all remote EnderChest installations registered with this one 

261 

262 Parameters 

263 ---------- 

264 minecraft_root : Path 

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

266 that's the parent of your EnderChest folder) 

267 log_level : int, optional 

268 By default, this method will report out the minecraft instances it 

269 finds at the INFO level. You can optionally pass in a lower (or higher) 

270 level if this method is being called from another method where that 

271 information is redundant or overly verbose. 

272 

273 Returns 

274 ------- 

275 list of (URI, str) tuples 

276 The URIs of the remote EnderChests, paired with their aliases 

277 

278 Notes 

279 ----- 

280 If no EnderChest is installed in the given location, then this will return 

281 an empty list rather than failing outright. 

282 """ 

283 try: 

284 ender_chest = load_ender_chest(minecraft_root) 

285 remotes: Sequence[tuple[ParseResult, str]] = ender_chest.remotes 

286 except (FileNotFoundError, ValueError) as bad_chest: 

287 INVENTORY_LOGGER.error( 

288 "Could not load EnderChest from %s:\n %s", minecraft_root, bad_chest 

289 ) 

290 remotes = () 

291 

292 if len(remotes) == 0: 

293 if log_level >= logging.INFO: 

294 INVENTORY_LOGGER.warning( 

295 "There are no remotes registered to the %s EnderChest", minecraft_root 

296 ) 

297 return [] 

298 

299 report = ( 

300 "These are the remote EnderChest installations registered" 

301 " to the one installed at %s" 

302 ) 

303 remote_list: list[tuple[ParseResult, str]] = [] 

304 log_args: list[Any] = [minecraft_root] 

305 for remote, alias in remotes: 

306 report += "\n - %s" 

307 log_args.append(render_remote(alias, remote)) 

308 remote_list.append((remote, alias)) 

309 INVENTORY_LOGGER.log(log_level, report, *log_args) 

310 return remote_list 

311 

312 

313def get_shulker_boxes_matching_instance( 

314 minecraft_root: Path, instance_name: str 

315) -> list[ShulkerBox]: 

316 """Get the list of shulker boxes that the specified instance links to 

317 

318 Parameters 

319 ---------- 

320 minecraft_root : Path 

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

322 that's the parent of your EnderChest folder) 

323 instance_name : str 

324 The name of the instance you're asking about 

325 

326 Returns 

327 ------- 

328 list of ShulkerBox 

329 The shulker boxes that are linked to by the specified instance 

330 """ 

331 try: 

332 chest = load_ender_chest(minecraft_root) 

333 except (FileNotFoundError, ValueError) as bad_chest: 

334 INVENTORY_LOGGER.error( 

335 "Could not load EnderChest from %s:\n %s", minecraft_root, bad_chest 

336 ) 

337 return [] 

338 for mc in chest.instances: 

339 if mc.name == instance_name: 

340 break 

341 else: 

342 INVENTORY_LOGGER.error( 

343 "No instance named %s is registered to this EnderChest", instance_name 

344 ) 

345 return [] 

346 

347 matches = [ 

348 box 

349 for box in load_shulker_boxes(minecraft_root, log_level=logging.DEBUG) 

350 if box.matches(mc) and box.matches_host(chest.name) 

351 ] 

352 

353 if len(matches) == 0: 

354 report = "does not link to any shulker boxes in this chest" 

355 else: 

356 # TODO: render as args 

357 report = "links to the following shulker boxes:\n" + "\n".join( 

358 f" - {_render_shulker_box(box)}" for box in matches 

359 ) 

360 

361 INVENTORY_LOGGER.info("The instance %s %s", render_instance(mc), report) 

362 

363 return matches 

364 

365 

366def get_instances_matching_shulker_box( 

367 minecraft_root: Path, shulker_box_name: str 

368) -> list[InstanceSpec]: 

369 """Get the list of registered instances that link to the specified shulker box 

370 

371 Parameters 

372 ---------- 

373 minecraft_root : Path 

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

375 that's the parent of your EnderChest folder) 

376 shulker_box_name : str 

377 The name of the shulker box you're asking about 

378 

379 Returns 

380 ------- 

381 list of InstanceSpec 

382 The instances that are / should be linked to the specified shulker box 

383 """ 

384 try: 

385 config_file = fs.shulker_box_config(minecraft_root, shulker_box_name) 

386 except FileNotFoundError: 

387 INVENTORY_LOGGER.error("No EnderChest is installed in %s", minecraft_root) 

388 return [] 

389 try: 

390 shulker_box = _load_shulker_box(config_file) 

391 except (FileNotFoundError, ValueError) as bad_box: 

392 INVENTORY_LOGGER.error( 

393 "Could not load shulker box %s\n %s", shulker_box_name, bad_box 

394 ) 

395 return [] 

396 

397 chest = load_ender_chest(minecraft_root) 

398 

399 if not shulker_box.matches_host(chest.name): 

400 INVENTORY_LOGGER.warning( 

401 "This shulker box will not link to any instances on this machine" 

402 ) 

403 return [] 

404 

405 if not chest.instances: 

406 INVENTORY_LOGGER.warning( 

407 "This EnderChest does not have any instances registered." 

408 " To register some, run the command:" 

409 "\nenderchest gather minecraft", 

410 ) 

411 return [] 

412 

413 INVENTORY_LOGGER.debug( 

414 "These are the instances that are currently registered" 

415 " to the %s EnderChest:\n%s", 

416 minecraft_root, 

417 "\n".join( 

418 [ 

419 f" {i + 1}. {render_instance(instance)}" 

420 for i, instance in enumerate(chest.instances) 

421 ] 

422 ), 

423 ) 

424 

425 matches = [ 

426 instance for instance in chest.instances if shulker_box.matches(instance) 

427 ] 

428 

429 if len(matches) == 0: 

430 report = "is not linked to by any registered instances" 

431 else: 

432 report = "is linked to by the following instances:\n" + "\n".join( 

433 f" - {render_instance(instance)}" for instance in matches 

434 ) 

435 

436 INVENTORY_LOGGER.info( 

437 "The shulker box %s %s", _render_shulker_box(shulker_box), report 

438 ) 

439 

440 return matches