Coverage for enderchest/enderchest.py: 99%
165 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-03 20:14 +0000
« 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
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
16@dataclass(init=False, repr=False, eq=False)
17class EnderChest:
18 """Configuration of an EnderChest
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.
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 """
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"]
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
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)
115 self.name = name or self._uri.hostname or gethostname()
117 self._instances = []
118 self._remotes = {}
120 for instance in instances or ():
121 self.register_instance(instance)
123 for remote in remotes or ():
124 if isinstance(remote, (str, ParseResult)):
125 self.register_remote(remote)
126 else:
127 self.register_remote(*remote)
129 @property
130 def uri(self) -> str:
131 return self._uri.geturl()
133 def __repr__(self) -> str:
134 return f"EnderChest({self.uri, self.name})"
136 @property
137 def root(self) -> Path:
138 return fs.ender_chest_folder(abspath_from_uri(self._uri), check_exists=False)
140 @property
141 def instances(self) -> tuple[i.InstanceSpec, ...]:
142 return tuple(self._instances)
144 def register_instance(self, instance: i.InstanceSpec) -> i.InstanceSpec:
145 """Register a new Minecraft installation
147 Parameters
148 ----------
149 instance : InstanceSpec
150 The instance to register
152 Returns
153 -------
154 InstanceSpec
155 The spec of the instance as it was actually registered (in case the
156 name changed or somesuch)
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)
171 instance = i.merge(*matching_instances, instance)
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}"
182 GATHER_LOGGER.debug(f"Registering instance {name} at {instance.root}")
183 self._instances.append(instance._replace(name=name))
184 return self._instances[-1]
186 @property
187 def remotes(self) -> tuple[tuple[ParseResult, str], ...]:
188 return tuple((remote, alias) for alias, remote in self._remotes.items())
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)
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.
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
219 @classmethod
220 def from_cfg(cls, config_file: Path) -> "EnderChest":
221 """Parse an EnderChest from its config file
223 Parameters
224 ----------
225 config_file : Path
226 The path to the config file
228 Returns
229 -------
230 EnderChest
231 The resulting EnderChest
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)
243 # All I'm gonna say is that Windows pathing is the worst
244 path = urlparse(config_file.absolute().parent.parent.as_uri()).path
246 instances: list[i.InstanceSpec] = []
247 remotes: list[str | tuple[str, str]] = []
249 requires_rewrite = False
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
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]))
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 )
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
315 ender_chest.offer_to_update_symlink_allowlist = (
316 offer_to_update_symlink_allowlist
317 )
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
333 if requires_rewrite:
334 ender_chest.write_to_cfg(config_file)
335 return cls.from_cfg(config_file)
336 return ender_chest
338 def write_to_cfg(self, config_file: Path | None = None) -> str:
339 """Write this EnderChest's configuration to INI
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
347 Returns
348 -------
349 str
350 An INI-syntax rendering of this EnderChest's config
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
366 properties["place-after-open"] = self.place_after_open
367 properties[
368 "offer-to-update-symlink-allowlist"
369 ] = self.offer_to_update_symlink_allowlist
371 properties["do-not-sync"] = self.do_not_sync
373 remotes: dict[str, str] = {name: uri.geturl() for uri, name in self.remotes}
375 instances: dict[str, dict[str, Any]] = {}
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 }
386 config = cfg.dumps(
387 fs.ENDER_CHEST_CONFIG_NAME, properties, remotes=remotes, **instances
388 )
390 if config_file:
391 CRAFT_LOGGER.debug("Writing configuration file to %s", config_file)
392 config_file.write_text(config)
393 return config
396def create_ender_chest(minecraft_root: Path, ender_chest: EnderChest) -> None:
397 """Create an EnderChest based on the provided configuration
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
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)
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}")