Skip to content

enderchest

Top-level imports

EnderChest dataclass

Configuration of an EnderChest

Parameters:

Name Type Description Default
uri URI or Path

The "address" of this EnderChest, ideally as it can be accessed from other EnderChest installations, including both the path to where the EnderChest folder can be found (that is, the parent of the EnderChest folder itself, aka the "minecraft_root"), its net location including credentials, and the protocol that should be used to perform the syncing. All that being said, if just a path is provided, the constructor will try to figure out the rest.

required
name str

A unique name to give to this EnderChest installation. If None is provided, this will be taken from the hostname of the supplied URI.

None
instances list-like of InstanceSpec

The list of instances to register with this EnderChest installation

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

A list of other installations that this EnderChest should be aware of (for syncing purposes). When a (URI, str) tuple is provided, the second value will be used as the name/alias of the remote.

None

Attributes:

Name Type Description
name str

The unique name of this EnderChest installation. This is most commonly the computer's hostname, but one can configure multiple EnderChests to coexist on the same system (either for the sake of having a "cold" backup or for multi-user systems).

uri str

The complete URI of this instance

root Path

The path to this EnderChest folder

instances list-like of InstanceSpec

The instances registered with this EnderChest

remotes list-like of (ParseResult, str) pairs

The other EnderChest installations this EnderChest is aware of, paired with their aliases

offer_to_update_symlink_allowlist bool

By default, EnderChest will offer to create or update allowed_symlinks.txt on any 1.20+ instances that do not already blanket allow links into EnderChest. EnderChest will never modify that or any other Minecraft file without your express consent. If you would prefer to edit these files yourself (or simply not symlink your world saves), change this parameter to False.

sync_confirm_wait bool or int

The default behavior when syncing EnderChests is to first perform a dry run of every sync operation and then wait 5 seconds before proceeding with the real sync. The idea is to give the user time to interrupt the sync if the dry run looks wrong. This can be changed by either raising or lowering the value of confirm, by disabling the dry-run-first behavior entirely (confirm=False) or by requiring that the user explicitly confirms the sync (confirm=True). This default behavior can also be overridden when actually calling the sync commands.

place_after_open bool

By default, EnderChest will follow up any enderchest open operation with an enderchest place to refresh any changed symlinks. This functionality can be disabled by setting this parameter to False.

do_not_sync list of str

Glob patterns of files that should not be synced between EnderChest installations. By default, this list comprises EnderChest/enderchest.cfg, any top-level folders starting with a "." (like .git) and .DS_Store (for all you mac gamers).

shulker_box_folders list of str

The folders that will be created inside each new shulker box

standard_link_folders list of str

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

global_link_folders list of str

The "global" set of "link folders," offered as a suggestion when crafting a new shulker box

Source code in enderchest/enderchest.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
@dataclass(init=False, repr=False, eq=False)
class EnderChest:
    """Configuration of an EnderChest

    Parameters
    ----------
    uri : URI or Path
        The "address" of this EnderChest, ideally as it can be accessed from other
        EnderChest installations, including both the path to where
        the EnderChest folder can be found (that is, the parent of the
        EnderChest folder itself, aka the "minecraft_root"), its net location
        including credentials, and the protocol that should be used to perform
        the syncing. All that being said, if just a path is provided, the
        constructor will try to figure out the rest.
    name : str, optional
        A unique name to give to this EnderChest installation. If None is
        provided, this will be taken from the hostname of the supplied URI.
    instances : list-like of InstanceSpec, optional
        The list of instances to register with this EnderChest installation
    remotes : list-like of URI, or (URI, str) tuples
        A list of other installations that this EnderChest should be aware of
        (for syncing purposes). When a (URI, str) tuple is provided, the
        second value will be used as the name/alias of the remote.

    Attributes
    ----------
    name : str
        The unique name of this EnderChest installation. This is most commonly
        the computer's hostname, but one can configure multiple EnderChests
        to coexist on the same system (either for the sake of having a "cold"
        backup or for multi-user systems).
    uri : str
        The complete URI of this instance
    root : Path
        The path to this EnderChest folder
    instances : list-like of InstanceSpec
        The instances registered with this EnderChest
    remotes : list-like of (ParseResult, str) pairs
        The other EnderChest installations this EnderChest is aware of, paired
        with their aliases
    offer_to_update_symlink_allowlist : bool
        By default, EnderChest will offer to create or update `allowed_symlinks.txt`
        on any 1.20+ instances that do not already blanket allow links into
        EnderChest. **EnderChest will never modify that or any other Minecraft
        file without your express consent.** If you would prefer to edit these
        files yourself (or simply not symlink your world saves), change this
        parameter to False.
    sync_confirm_wait : bool or int
        The default behavior when syncing EnderChests is to first perform a dry
        run of every sync operation and then wait 5 seconds before proceeding with the
        real sync. The idea is to give the user time to interrupt the sync if
        the dry run looks wrong. This can be changed by either raising or lowering
        the value of confirm, by disabling the dry-run-first behavior entirely
        (`confirm=False`) or by requiring that the user explicitly confirms
        the sync (`confirm=True`). This default behavior can also be overridden
        when actually calling the sync commands.
    place_after_open: bool
        By default, EnderChest will follow up any `enderchest open` operation
        with an `enderchest place` to refresh any changed symlinks. This
        functionality can be disabled by setting this parameter to False.
    do_not_sync : list of str
        Glob patterns of files that should not be synced between EnderChest
        installations. By default, this list comprises `EnderChest/enderchest.cfg`,
        any top-level folders starting with a "." (like .git) and
        `.DS_Store` (for all you mac gamers).
    shulker_box_folders : list of str
        The folders that will be created inside each new shulker box
    standard_link_folders : list of str
        The default set of "link folders" when crafting a new shulker box
    global_link_folders : list of str
        The "global" set of "link folders," offered as a suggestion when
        crafting a new shulker box
    """

    name: str
    _uri: ParseResult
    _instances: list[i.InstanceSpec]
    _remotes: dict[str, ParseResult]
    offer_to_update_symlink_allowlist: bool
    sync_confirm_wait: bool | int
    place_after_open: bool
    do_not_sync: list[str]
    shulker_box_folders: list[str]
    standard_link_folders: list[str]
    global_link_folders: list[str]

    def __init__(
        self,
        uri: str | ParseResult | Path,
        name: str | None = None,
        remotes: (
            Iterable[str | ParseResult | tuple[str, str] | tuple[ParseResult, str]]
            | None
        ) = None,
        instances: Iterable[i.InstanceSpec] | None = None,
    ):
        for setting, value in _DEFAULTS:
            setattr(self, setting, list(value) if isinstance(value, tuple) else value)

        try:
            if isinstance(uri, ParseResult):
                self._uri = uri
            elif isinstance(uri, Path):
                self._uri = urlparse(uri.absolute().as_uri())
            else:
                self._uri = urlparse(uri)
        except AttributeError as parse_problem:  # pragma: no cover
            raise ValueError(f"{uri} is not a valid URI") from parse_problem

        if not self._uri.netloc:
            self._uri = self._uri._replace(netloc=sync.get_default_netloc())
        if not self._uri.scheme:
            self._uri = self._uri._replace(scheme=sync.DEFAULT_PROTOCOL)

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

        self._instances = []
        self._remotes = {}

        for instance in instances or ():
            self.register_instance(instance)

        for remote in remotes or ():
            if isinstance(remote, (str, ParseResult)):
                self.register_remote(remote)
            else:
                self.register_remote(*remote)

    @property
    def uri(self) -> str:
        return self._uri.geturl()

    def __repr__(self) -> str:
        return f"EnderChest({self.uri, self.name})"

    @property
    def root(self) -> Path:
        return fs.ender_chest_folder(abspath_from_uri(self._uri), check_exists=False)

    @property
    def instances(self) -> tuple[i.InstanceSpec, ...]:
        return tuple(self._instances)

    def register_instance(self, instance: i.InstanceSpec) -> i.InstanceSpec:
        """Register a new Minecraft installation

        Parameters
        ----------
        instance : InstanceSpec
            The instance to register

        Returns
        -------
        InstanceSpec
            The spec of the instance as it was actually registered (in case the
            name changed or somesuch)

        Notes
        -----
        - If the instance's name is already assigned to a registered instance,
          this method will choose a new one
        - If this instance shares a path with an existing instance, it will
          replace that instance
        """
        matching_instances: list[i.InstanceSpec] = []
        for old_instance in self._instances:
            if i.equals(abspath_from_uri(self._uri), instance, old_instance):
                matching_instances.append(old_instance)
                self._instances.remove(old_instance)

        instance = i.merge(*matching_instances, instance)

        name = instance.name
        counter = 0
        taken_names = {old_instance.name for old_instance in self._instances}
        while True:
            if name not in taken_names:
                break
            counter += 1
            name = f"{instance.name}.{counter}"

        GATHER_LOGGER.debug(f"Registering instance {name} at {instance.root}")
        self._instances.append(instance._replace(name=name))
        return self._instances[-1]

    @property
    def remotes(self) -> tuple[tuple[ParseResult, str], ...]:
        return tuple((remote, alias) for alias, remote in self._remotes.items())

    def register_remote(
        self, remote: str | ParseResult, alias: str | None = None
    ) -> None:
        """Register a new remote EnderChest installation (or update an existing
        registry)

        Parameters
        ----------
        remote : URI
            The URI of the remote
        alias : str, optional
            an alias to give to this remote. If None is provided, the URI's hostname
            will be used.

        Raises
        ------
        ValueError
            If the provided remote is invalid
        """
        try:
            remote = remote if isinstance(remote, ParseResult) else urlparse(remote)
            alias = alias or remote.hostname
            if not alias:  # pragma: no cover
                raise AttributeError(f"{remote.geturl()} has no hostname")
            GATHER_LOGGER.debug("Registering remote %s (%s)", remote.geturl(), alias)
            self._remotes[alias] = remote
        except AttributeError as parse_problem:  # pragma: no cover
            raise ValueError(f"{remote} is not a valid URI") from parse_problem

    @classmethod
    def from_cfg(cls, config_file: Path) -> "EnderChest":
        """Parse an EnderChest from its config file

        Parameters
        ----------
        config_file : Path
            The path to the config file

        Returns
        -------
        EnderChest
            The resulting EnderChest

        Raises
        ------
        ValueError
            If the config file at that location cannot be parsed
        FileNotFoundError
            If there is no config file at the specified location
        """
        INVENTORY_LOGGER.debug("Reading config file from %s", config_file)
        config = cfg.read_cfg(config_file)

        # All I'm gonna say is that Windows pathing is the worst
        path = urlparse(config_file.absolute().parent.parent.as_uri()).path

        instances: list[i.InstanceSpec] = []
        remotes: list[str | tuple[str, str]] = []

        requires_rewrite = False

        scheme: str | None = None
        netloc: str | None = None
        name: str | None = None
        sync_confirm_wait: str | None = None
        place_after_open: bool | None = None
        offer_to_update_symlink_allowlist: bool = True
        do_not_sync: list[str] | None = None
        folder_defaults: dict[str, list[str] | None] = {
            "shulker_box_folders": None,
            "standard_link_folders": None,
            "global_link_folders": None,
        }

        for section in config.sections():
            if section == "properties":
                scheme = config[section].get("sync-protocol")
                netloc = config[section].get("address")
                name = config[section].get("name")
                sync_confirm_wait = config[section].get("sync-confirmation-time")
                place_after_open = config[section].getboolean("place-after-open")
                offer_to_update_symlink_allowlist = config[section].getboolean(
                    "offer-to-update-symlink-allowlist", True
                )
                if "do-not-sync" in config[section].keys():
                    do_not_sync = cfg.parse_ini_list(
                        config[section]["do-not-sync"] or ""
                    )
                for setting in folder_defaults.keys():
                    setting_key = setting.replace("_", "-")
                    if setting_key in config[section].keys():
                        folder_defaults[setting] = cfg.parse_ini_list(
                            config[section][setting_key] or ""
                        )
            elif section == "remotes":
                for remote in config[section].items():
                    if remote[1] is None:
                        raise ValueError("All remotes must have an alias specified")
                    remotes.append((remote[1], remote[0]))
            else:
                # TODO: flag requires_rewrite if instance was normalized
                instances.append(i.InstanceSpec.from_cfg(config[section]))

        scheme = scheme or sync.DEFAULT_PROTOCOL
        netloc = netloc or sync.get_default_netloc()
        uri = ParseResult(
            scheme=scheme, netloc=netloc, path=path, params="", query="", fragment=""
        )

        ender_chest = EnderChest(uri, name, remotes, instances)
        if sync_confirm_wait is not None:
            match sync_confirm_wait.lower():
                case "true" | "prompt" | "yes" | "confirm":
                    ender_chest.sync_confirm_wait = True
                case "false" | "no" | "skip":
                    ender_chest.sync_confirm_wait = False
                case _:
                    try:
                        ender_chest.sync_confirm_wait = int(sync_confirm_wait)
                    except ValueError as bad_input:
                        raise ValueError(
                            "Invalid value for sync-confirmation-time:"
                            f" {sync_confirm_wait}"
                        ) from bad_input
        if place_after_open is None:
            INVENTORY_LOGGER.warning(
                "This EnderChest does not have a value set for place-after-open."
                "\nIt is being set to False for now. To enable this functionality,"
                "\nedit the value in %s",
                config_file,
            )
            ender_chest.place_after_open = False
            requires_rewrite = True
        else:
            ender_chest.place_after_open = place_after_open

        ender_chest.offer_to_update_symlink_allowlist = (
            offer_to_update_symlink_allowlist
        )

        if do_not_sync is not None:
            ender_chest.do_not_sync = do_not_sync
            chest_cfg_exclusion = "/".join(
                (fs.ENDER_CHEST_FOLDER_NAME, fs.ENDER_CHEST_CONFIG_NAME)
            )
            if chest_cfg_exclusion not in do_not_sync:
                INVENTORY_LOGGER.warning(
                    "This EnderChest was not configured to exclude the EnderChest"
                    " config file from sync operations."
                    "\nThat is being fixed now."
                )
                ender_chest.do_not_sync.insert(0, chest_cfg_exclusion)
                requires_rewrite = True
        for setting in folder_defaults.keys():
            if folder_defaults[setting] is None:
                folder_defaults[setting] = dict(_DEFAULTS)[setting]  # type: ignore
                # requires_rewrite = True  # though I'm considering it
            setattr(ender_chest, setting, folder_defaults[setting])

        if requires_rewrite:
            ender_chest.write_to_cfg(config_file)
            return cls.from_cfg(config_file)
        return ender_chest

    def write_to_cfg(self, config_file: Path | None = None) -> str:
        """Write this EnderChest's configuration to INI

        Parameters
        ----------
        config_file : Path, optional
            The path to the config file, assuming you'd like to write the
            contents to file

        Returns
        -------
        str
            An INI-syntax rendering of this EnderChest's config

        Notes
        -----
        The "root" attribute is ignored for this method
        """
        properties: dict[str, Any] = {
            "name": self.name,
            "address": self._uri.netloc,
            "sync-protocol": self._uri.scheme,
        }
        if self.sync_confirm_wait is True:
            properties["sync-confirmation-time"] = "prompt"
        else:
            properties["sync-confirmation-time"] = self.sync_confirm_wait

        for setting, _ in _DEFAULTS:
            if setting == "sync_confirm_wait":
                continue  # already did this one
            setting_key = setting.replace("_", "-")
            properties[setting_key] = getattr(self, setting)

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

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

        for instance in self.instances:
            instances[instance.name] = {
                "root": instance.root,
                "minecraft-version": instance.minecraft_versions,
                "modloader": instance.modloader,
                "groups": instance.groups_,
                "tags": instance.tags_,
            }

        config = cfg.dumps(
            fs.ENDER_CHEST_CONFIG_NAME, properties, remotes=remotes, **instances
        )

        if config_file:
            CRAFT_LOGGER.debug("Writing configuration file to %s", config_file)
            config_file.write_text(config)
        return config

from_cfg(config_file) classmethod

Parse an EnderChest from its config file

Parameters:

Name Type Description Default
config_file Path

The path to the config file

required

Returns:

Type Description
EnderChest

The resulting EnderChest

Raises:

Type Description
ValueError

If the config file at that location cannot be parsed

FileNotFoundError

If there is no config file at the specified location

Source code in enderchest/enderchest.py
@classmethod
def from_cfg(cls, config_file: Path) -> "EnderChest":
    """Parse an EnderChest from its config file

    Parameters
    ----------
    config_file : Path
        The path to the config file

    Returns
    -------
    EnderChest
        The resulting EnderChest

    Raises
    ------
    ValueError
        If the config file at that location cannot be parsed
    FileNotFoundError
        If there is no config file at the specified location
    """
    INVENTORY_LOGGER.debug("Reading config file from %s", config_file)
    config = cfg.read_cfg(config_file)

    # All I'm gonna say is that Windows pathing is the worst
    path = urlparse(config_file.absolute().parent.parent.as_uri()).path

    instances: list[i.InstanceSpec] = []
    remotes: list[str | tuple[str, str]] = []

    requires_rewrite = False

    scheme: str | None = None
    netloc: str | None = None
    name: str | None = None
    sync_confirm_wait: str | None = None
    place_after_open: bool | None = None
    offer_to_update_symlink_allowlist: bool = True
    do_not_sync: list[str] | None = None
    folder_defaults: dict[str, list[str] | None] = {
        "shulker_box_folders": None,
        "standard_link_folders": None,
        "global_link_folders": None,
    }

    for section in config.sections():
        if section == "properties":
            scheme = config[section].get("sync-protocol")
            netloc = config[section].get("address")
            name = config[section].get("name")
            sync_confirm_wait = config[section].get("sync-confirmation-time")
            place_after_open = config[section].getboolean("place-after-open")
            offer_to_update_symlink_allowlist = config[section].getboolean(
                "offer-to-update-symlink-allowlist", True
            )
            if "do-not-sync" in config[section].keys():
                do_not_sync = cfg.parse_ini_list(
                    config[section]["do-not-sync"] or ""
                )
            for setting in folder_defaults.keys():
                setting_key = setting.replace("_", "-")
                if setting_key in config[section].keys():
                    folder_defaults[setting] = cfg.parse_ini_list(
                        config[section][setting_key] or ""
                    )
        elif section == "remotes":
            for remote in config[section].items():
                if remote[1] is None:
                    raise ValueError("All remotes must have an alias specified")
                remotes.append((remote[1], remote[0]))
        else:
            # TODO: flag requires_rewrite if instance was normalized
            instances.append(i.InstanceSpec.from_cfg(config[section]))

    scheme = scheme or sync.DEFAULT_PROTOCOL
    netloc = netloc or sync.get_default_netloc()
    uri = ParseResult(
        scheme=scheme, netloc=netloc, path=path, params="", query="", fragment=""
    )

    ender_chest = EnderChest(uri, name, remotes, instances)
    if sync_confirm_wait is not None:
        match sync_confirm_wait.lower():
            case "true" | "prompt" | "yes" | "confirm":
                ender_chest.sync_confirm_wait = True
            case "false" | "no" | "skip":
                ender_chest.sync_confirm_wait = False
            case _:
                try:
                    ender_chest.sync_confirm_wait = int(sync_confirm_wait)
                except ValueError as bad_input:
                    raise ValueError(
                        "Invalid value for sync-confirmation-time:"
                        f" {sync_confirm_wait}"
                    ) from bad_input
    if place_after_open is None:
        INVENTORY_LOGGER.warning(
            "This EnderChest does not have a value set for place-after-open."
            "\nIt is being set to False for now. To enable this functionality,"
            "\nedit the value in %s",
            config_file,
        )
        ender_chest.place_after_open = False
        requires_rewrite = True
    else:
        ender_chest.place_after_open = place_after_open

    ender_chest.offer_to_update_symlink_allowlist = (
        offer_to_update_symlink_allowlist
    )

    if do_not_sync is not None:
        ender_chest.do_not_sync = do_not_sync
        chest_cfg_exclusion = "/".join(
            (fs.ENDER_CHEST_FOLDER_NAME, fs.ENDER_CHEST_CONFIG_NAME)
        )
        if chest_cfg_exclusion not in do_not_sync:
            INVENTORY_LOGGER.warning(
                "This EnderChest was not configured to exclude the EnderChest"
                " config file from sync operations."
                "\nThat is being fixed now."
            )
            ender_chest.do_not_sync.insert(0, chest_cfg_exclusion)
            requires_rewrite = True
    for setting in folder_defaults.keys():
        if folder_defaults[setting] is None:
            folder_defaults[setting] = dict(_DEFAULTS)[setting]  # type: ignore
            # requires_rewrite = True  # though I'm considering it
        setattr(ender_chest, setting, folder_defaults[setting])

    if requires_rewrite:
        ender_chest.write_to_cfg(config_file)
        return cls.from_cfg(config_file)
    return ender_chest

register_instance(instance)

Register a new Minecraft installation

Parameters:

Name Type Description Default
instance InstanceSpec

The instance to register

required

Returns:

Type Description
InstanceSpec

The spec of the instance as it was actually registered (in case the name changed or somesuch)

Notes
  • If the instance's name is already assigned to a registered instance, this method will choose a new one
  • If this instance shares a path with an existing instance, it will replace that instance
Source code in enderchest/enderchest.py
def register_instance(self, instance: i.InstanceSpec) -> i.InstanceSpec:
    """Register a new Minecraft installation

    Parameters
    ----------
    instance : InstanceSpec
        The instance to register

    Returns
    -------
    InstanceSpec
        The spec of the instance as it was actually registered (in case the
        name changed or somesuch)

    Notes
    -----
    - If the instance's name is already assigned to a registered instance,
      this method will choose a new one
    - If this instance shares a path with an existing instance, it will
      replace that instance
    """
    matching_instances: list[i.InstanceSpec] = []
    for old_instance in self._instances:
        if i.equals(abspath_from_uri(self._uri), instance, old_instance):
            matching_instances.append(old_instance)
            self._instances.remove(old_instance)

    instance = i.merge(*matching_instances, instance)

    name = instance.name
    counter = 0
    taken_names = {old_instance.name for old_instance in self._instances}
    while True:
        if name not in taken_names:
            break
        counter += 1
        name = f"{instance.name}.{counter}"

    GATHER_LOGGER.debug(f"Registering instance {name} at {instance.root}")
    self._instances.append(instance._replace(name=name))
    return self._instances[-1]

register_remote(remote, alias=None)

Register a new remote EnderChest installation (or update an existing registry)

Parameters:

Name Type Description Default
remote URI

The URI of the remote

required
alias str

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

None

Raises:

Type Description
ValueError

If the provided remote is invalid

Source code in enderchest/enderchest.py
def register_remote(
    self, remote: str | ParseResult, alias: str | None = None
) -> None:
    """Register a new remote EnderChest installation (or update an existing
    registry)

    Parameters
    ----------
    remote : URI
        The URI of the remote
    alias : str, optional
        an alias to give to this remote. If None is provided, the URI's hostname
        will be used.

    Raises
    ------
    ValueError
        If the provided remote is invalid
    """
    try:
        remote = remote if isinstance(remote, ParseResult) else urlparse(remote)
        alias = alias or remote.hostname
        if not alias:  # pragma: no cover
            raise AttributeError(f"{remote.geturl()} has no hostname")
        GATHER_LOGGER.debug("Registering remote %s (%s)", remote.geturl(), alias)
        self._remotes[alias] = remote
    except AttributeError as parse_problem:  # pragma: no cover
        raise ValueError(f"{remote} is not a valid URI") from parse_problem

write_to_cfg(config_file=None)

Write this EnderChest's configuration to INI

Parameters:

Name Type Description Default
config_file Path

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

None

Returns:

Type Description
str

An INI-syntax rendering of this EnderChest's config

Notes

The "root" attribute is ignored for this method

Source code in enderchest/enderchest.py
def write_to_cfg(self, config_file: Path | None = None) -> str:
    """Write this EnderChest's configuration to INI

    Parameters
    ----------
    config_file : Path, optional
        The path to the config file, assuming you'd like to write the
        contents to file

    Returns
    -------
    str
        An INI-syntax rendering of this EnderChest's config

    Notes
    -----
    The "root" attribute is ignored for this method
    """
    properties: dict[str, Any] = {
        "name": self.name,
        "address": self._uri.netloc,
        "sync-protocol": self._uri.scheme,
    }
    if self.sync_confirm_wait is True:
        properties["sync-confirmation-time"] = "prompt"
    else:
        properties["sync-confirmation-time"] = self.sync_confirm_wait

    for setting, _ in _DEFAULTS:
        if setting == "sync_confirm_wait":
            continue  # already did this one
        setting_key = setting.replace("_", "-")
        properties[setting_key] = getattr(self, setting)

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

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

    for instance in self.instances:
        instances[instance.name] = {
            "root": instance.root,
            "minecraft-version": instance.minecraft_versions,
            "modloader": instance.modloader,
            "groups": instance.groups_,
            "tags": instance.tags_,
        }

    config = cfg.dumps(
        fs.ENDER_CHEST_CONFIG_NAME, properties, remotes=remotes, **instances
    )

    if config_file:
        CRAFT_LOGGER.debug("Writing configuration file to %s", config_file)
        config_file.write_text(config)
    return config

InstanceSpec

Bases: NamedTuple

Specification of a Minecraft instance

Parameters:

Name Type Description Default
name str

The "display name" for the instance

required
root Path

The path to its ".minecraft" folder

required
minecraft_versions list-like of str

The minecraft versions of this instance. This is typically a 1-tuple, but some loaders (such as the official one) will just comingle all your assets together across all profiles

required
modloader str

The (display) name of the modloader (vanilla corresponds to "")

required
tags list-like of str

The tags assigned to this instance, including both the ones assigned in the launcher (groups) and the ones assigned by hand.

required
Source code in enderchest/instance.py
class InstanceSpec(NamedTuple):
    """Specification of a Minecraft instance

    Parameters
    ----------
    name : str
        The "display name" for the instance
    root : Path
        The path to its ".minecraft" folder
    minecraft_versions : list-like of str
        The minecraft versions of this instance. This is typically a 1-tuple,
        but some loaders (such as the official one) will just comingle all
        your assets together across all profiles
    modloader : str
        The (display) name of the modloader (vanilla corresponds to "")
    tags : list-like of str
        The tags assigned to this instance, including both the ones assigned
        in the launcher (groups) and the ones assigned by hand.
    """

    name: str
    root: Path
    minecraft_versions: tuple[str, ...]
    modloader: str
    groups_: tuple[str, ...]
    tags_: tuple[str, ...]

    @classmethod
    def from_cfg(cls, section: SectionProxy) -> "InstanceSpec":
        """Parse an instance spec as read in from the enderchest config file

        Parameters
        ----------
        section : dict-like of str to str
            The section in the enderchest config as parsed by a ConfigParser

        Returns
        -------
        InstanceSpec
            The resulting InstanceSpec

        Raises
        ------
        KeyError
            If a required key is absent
        ValueError
            If a required entry cannot be parsed
        """
        return cls(
            section.name,
            Path(section["root"]),
            tuple(
                parse_version(version.strip())
                for version in cfg.parse_ini_list(
                    section.get("minecraft-version", section.get("minecraft_version"))
                )
            ),
            normalize_modloader(section.get("modloader", None))[0],
            tuple(cfg.parse_ini_list(section.get("groups", ""))),
            tuple(cfg.parse_ini_list(section.get("tags", ""))),
        )

    @property
    def tags(self):
        return tuple(sorted({*self.groups_, *self.tags_}))

from_cfg(section) classmethod

Parse an instance spec as read in from the enderchest config file

Parameters:

Name Type Description Default
section dict-like of str to str

The section in the enderchest config as parsed by a ConfigParser

required

Returns:

Type Description
InstanceSpec

The resulting InstanceSpec

Raises:

Type Description
KeyError

If a required key is absent

ValueError

If a required entry cannot be parsed

Source code in enderchest/instance.py
@classmethod
def from_cfg(cls, section: SectionProxy) -> "InstanceSpec":
    """Parse an instance spec as read in from the enderchest config file

    Parameters
    ----------
    section : dict-like of str to str
        The section in the enderchest config as parsed by a ConfigParser

    Returns
    -------
    InstanceSpec
        The resulting InstanceSpec

    Raises
    ------
    KeyError
        If a required key is absent
    ValueError
        If a required entry cannot be parsed
    """
    return cls(
        section.name,
        Path(section["root"]),
        tuple(
            parse_version(version.strip())
            for version in cfg.parse_ini_list(
                section.get("minecraft-version", section.get("minecraft_version"))
            )
        ),
        normalize_modloader(section.get("modloader", None))[0],
        tuple(cfg.parse_ini_list(section.get("groups", ""))),
        tuple(cfg.parse_ini_list(section.get("tags", ""))),
    )

ShulkerBox

Bases: NamedTuple

Specification of a shulker box

Parameters:

Name Type Description Default
priority int

The priority for linking assets in the shulker box (higher priority boxes are linked last)

required
name str

The name of the shulker box (which is incidentally used to break priority ties)

required
root Path

The path to the root of the shulker box

required
match_criteria list-like of tuples

The parameters for matching instances to this shulker box. Each element consists of:

  • the name of the condition
  • the matching values for that condition

The logic applied is that an instance must match at least one value for each condition (so it's ANDing a collection of ORs)

required
link_folders list-like of str

The folders that should be linked in their entirety

required
max_link_depth int

By default, non-root-level folders (that is, folders inside of folders) will be treated as files for the purpose of linking. Put another way, only files with a depth of 2 or less from the shulker root will be linked. This behavior can be overridden by explicitly setting the max_link_depth value, but this feature is highly experimental, so use it at your own risk.

required
do_not_link list-like of str

Glob patterns of files that should not be linked. By default, this list comprises shulkerbox.cfg and .DS_Store (for all you mac gamers).

required
Notes

A shulker box specification is immutable, so making changes (such as updating the match criteria) can only be done on copies created via the _replace method, inherited from the NamedTuple parent class.

Source code in enderchest/shulker_box.py
class ShulkerBox(NamedTuple):
    """Specification of a shulker box

    Parameters
    ----------
    priority : int
        The priority for linking assets in the shulker box (higher priority
        boxes are linked last)
    name : str
        The name of the shulker box (which is incidentally used to break
        priority ties)
    root : Path
        The path to the root of the shulker box
    match_criteria : list-like of tuples
        The parameters for matching instances to this shulker box. Each element
        consists of:

          - the name of the condition
          - the matching values for that condition

        The logic applied is that an instance must match at least one value
        for each condition (so it's ANDing a collection of ORs)
    link_folders : list-like of str
        The folders that should be linked in their entirety
    max_link_depth : int, optional
        By default, non-root-level folders (that is, folders inside of folders)
        will be treated as files for the purpose of linking. Put another way,
        only files with a depth of 2 or less from the shulker root will be
        linked. This behavior can be overridden by explicitly setting
        the `max_link_depth` value, but **this feature is highly experimental**,
        so use it at your own risk.
    do_not_link : list-like of str, optional
        Glob patterns of files that should not be linked. By default, this list
        comprises `shulkerbox.cfg` and `.DS_Store` (for all you mac gamers).

    Notes
    -----
    A shulker box specification is immutable, so making changes (such as
    updating the match criteria) can only be done on copies created via the
    `_replace` method, inherited from the NamedTuple parent class.
    """

    priority: int
    name: str
    root: Path
    match_criteria: tuple[tuple[str, tuple[str, ...]], ...]
    link_folders: tuple[str, ...]
    max_link_depth: int = _DEFAULT_LINK_DEPTH
    do_not_link: tuple[str, ...] = _DEFAULT_DO_NOT_LINK

    @classmethod
    def from_cfg(cls, config_file: Path) -> "ShulkerBox":
        """Parse a shulker box from its config file

        Parameters
        ----------
        config_file : Path
            The path to the config file

        Returns
        -------
        ShulkerBox
            The resulting ShulkerBox

        Raises
        ------
        ValueError
            If the config file at that location cannot be parsed
        FileNotFoundError
            If there is no config file at the specified location
        """
        priority = 0
        max_link_depth = 2
        root = config_file.parent
        name = root.name
        config = cfg.read_cfg(config_file)

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

        for section in config.sections():
            normalized = (
                section.lower().replace(" ", "").replace("-", "").replace("_", "")
            )
            if normalized.endswith("s"):
                normalized = normalized[:-1]  # lazy de-pluralization
            if normalized in ("linkfolder", "folder"):
                normalized = "link-folders"
            if normalized in ("donotlink",):
                normalized = "do-not-link"
            if normalized in ("minecraft", "version", "minecraftversion"):
                normalized = "minecraft"
            if normalized in ("modloader", "loader"):
                normalized = "modloader"
            if normalized in ("instance", "tag", "host"):
                normalized += "s"  # lazy re-pluralization

            if normalized == "propertie":  # lulz
                # TODO check to make sure properties hasn't been read before
                # most of this section gets ignored
                priority = config[section].getint("priority", _DEFAULT_PRIORITY)
                max_link_depth = config[section].getint(
                    "max-link-depth", _DEFAULT_LINK_DEPTH
                )
                # TODO: support specifying filters (and link-folders) in the properties section
                continue
            if normalized in match_criteria:
                raise ValueError(f"{config_file} specifies {normalized} more than once")

            if normalized == "minecraft":
                minecraft_versions = []
                for key, value in config[section].items():
                    if value is None:
                        minecraft_versions.append(key)
                    elif key.lower().strip().startswith("version"):
                        minecraft_versions.append(value)
                    else:  # what happens if you specify ">=1.19" or "=1.12"
                        minecraft_versions.append("=".join((key, value)))
                match_criteria[normalized] = tuple(minecraft_versions)
            elif normalized == "modloader":
                modloaders: set[str] = set()
                for loader in config[section].keys():
                    modloaders.update(normalize_modloader(loader))
                match_criteria[normalized] = tuple(sorted(modloaders))
            else:
                # really hoping delimiter shenanigans doesn't show up anywhere else
                match_criteria[normalized] = tuple(config[section].keys())

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

        return cls(
            priority,
            name,
            root,
            tuple(match_criteria.items()),
            link_folders,
            max_link_depth=max_link_depth,
            do_not_link=do_not_link,
        )

    def write_to_cfg(self, config_file: Path | None = None) -> str:
        """Write this box's configuration to INI

        Parameters
        ----------
        config_file : Path, optional
            The path to the config file, assuming you'd like to write the
            contents to file

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

        Notes
        -----
        The "root" attribute is ignored for this method
        """
        properties: dict[str, Any] = {"priority": self.priority}
        if self.max_link_depth != _DEFAULT_LINK_DEPTH:
            properties["max-link-depth"] = self.max_link_depth

        config = cfg.dumps(
            os.path.join(self.name, fs.SHULKER_BOX_CONFIG_NAME),
            properties,
            **dict(self.match_criteria),
            link_folders=self.link_folders,
            do_not_link=self.do_not_link,
        )

        if config_file:
            config_file.write_text(config)
        return config

    def matches(self, instance: InstanceSpec) -> bool:
        """Determine whether the shulker box matches the given instance

        Parameters
        ----------
        instance : InstanceSpec
            The instance's specification

        Returns
        -------
        bool
            True if the instance matches the shulker box's conditions, False
            otherwise.
        """
        for condition, values in self.match_criteria:
            match condition:  # these should have been normalized on read-in
                case "instances":
                    matchers = []
                    exclusions = []
                    for value in values:
                        if value.startswith("!"):
                            exclusions.append(value[1:])
                        else:
                            matchers.append(value)
                    for value in exclusions:
                        if _matches_string(value, instance.name, case_sensitive=True):
                            return False

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

                    for value in matchers:
                        if _matches_string(value, instance.name, case_sensitive=True):
                            break
                    else:
                        return False

                case "tags":
                    matchers = []
                    exclusions = []
                    for value in values:
                        if value.startswith("!"):
                            exclusions.append(value[1:])
                        else:
                            matchers.append(value)

                    for value in exclusions:
                        for tag in instance.tags:
                            if _matches_string(value, tag):
                                return False

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

                    for value in matchers:
                        if value == "*":  # in case instance.tags is empty
                            break
                        for tag in instance.tags:
                            if _matches_string(value, tag):
                                break
                        else:
                            continue
                        break
                    else:
                        return False

                case "modloader":
                    for value in values:
                        if _matches_string(value, instance.modloader):
                            break
                    else:
                        return False

                case "minecraft":
                    for value in values:
                        if any(
                            (
                                _matches_version(value, version)
                                for version in instance.minecraft_versions
                            )
                        ):
                            break
                    else:
                        return False

                case "hosts":
                    # this is handled at a higher level
                    pass

                case _:
                    raise NotImplementedError(
                        f"Don't know how to apply match condition {condition}."
                    )
        return True

    def matches_host(self, hostname: str):
        """Determine whether the shulker box should be linked to from the
        current host machine

        Returns
        -------
        bool
            True if the shulker box's hosts spec matches the host, False otherwise.
        """
        for condition, values in self.match_criteria:
            if condition == "hosts":
                if not any(
                    fnmatch.fnmatchcase(hostname.lower(), host_spec.lower())
                    for host_spec in values
                ):
                    return False
        return True

from_cfg(config_file) classmethod

Parse a shulker box from its config file

Parameters:

Name Type Description Default
config_file Path

The path to the config file

required

Returns:

Type Description
ShulkerBox

The resulting ShulkerBox

Raises:

Type Description
ValueError

If the config file at that location cannot be parsed

FileNotFoundError

If there is no config file at the specified location

Source code in enderchest/shulker_box.py
@classmethod
def from_cfg(cls, config_file: Path) -> "ShulkerBox":
    """Parse a shulker box from its config file

    Parameters
    ----------
    config_file : Path
        The path to the config file

    Returns
    -------
    ShulkerBox
        The resulting ShulkerBox

    Raises
    ------
    ValueError
        If the config file at that location cannot be parsed
    FileNotFoundError
        If there is no config file at the specified location
    """
    priority = 0
    max_link_depth = 2
    root = config_file.parent
    name = root.name
    config = cfg.read_cfg(config_file)

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

    for section in config.sections():
        normalized = (
            section.lower().replace(" ", "").replace("-", "").replace("_", "")
        )
        if normalized.endswith("s"):
            normalized = normalized[:-1]  # lazy de-pluralization
        if normalized in ("linkfolder", "folder"):
            normalized = "link-folders"
        if normalized in ("donotlink",):
            normalized = "do-not-link"
        if normalized in ("minecraft", "version", "minecraftversion"):
            normalized = "minecraft"
        if normalized in ("modloader", "loader"):
            normalized = "modloader"
        if normalized in ("instance", "tag", "host"):
            normalized += "s"  # lazy re-pluralization

        if normalized == "propertie":  # lulz
            # TODO check to make sure properties hasn't been read before
            # most of this section gets ignored
            priority = config[section].getint("priority", _DEFAULT_PRIORITY)
            max_link_depth = config[section].getint(
                "max-link-depth", _DEFAULT_LINK_DEPTH
            )
            # TODO: support specifying filters (and link-folders) in the properties section
            continue
        if normalized in match_criteria:
            raise ValueError(f"{config_file} specifies {normalized} more than once")

        if normalized == "minecraft":
            minecraft_versions = []
            for key, value in config[section].items():
                if value is None:
                    minecraft_versions.append(key)
                elif key.lower().strip().startswith("version"):
                    minecraft_versions.append(value)
                else:  # what happens if you specify ">=1.19" or "=1.12"
                    minecraft_versions.append("=".join((key, value)))
            match_criteria[normalized] = tuple(minecraft_versions)
        elif normalized == "modloader":
            modloaders: set[str] = set()
            for loader in config[section].keys():
                modloaders.update(normalize_modloader(loader))
            match_criteria[normalized] = tuple(sorted(modloaders))
        else:
            # really hoping delimiter shenanigans doesn't show up anywhere else
            match_criteria[normalized] = tuple(config[section].keys())

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

    return cls(
        priority,
        name,
        root,
        tuple(match_criteria.items()),
        link_folders,
        max_link_depth=max_link_depth,
        do_not_link=do_not_link,
    )

matches(instance)

Determine whether the shulker box matches the given instance

Parameters:

Name Type Description Default
instance InstanceSpec

The instance's specification

required

Returns:

Type Description
bool

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

Source code in enderchest/shulker_box.py
def matches(self, instance: InstanceSpec) -> bool:
    """Determine whether the shulker box matches the given instance

    Parameters
    ----------
    instance : InstanceSpec
        The instance's specification

    Returns
    -------
    bool
        True if the instance matches the shulker box's conditions, False
        otherwise.
    """
    for condition, values in self.match_criteria:
        match condition:  # these should have been normalized on read-in
            case "instances":
                matchers = []
                exclusions = []
                for value in values:
                    if value.startswith("!"):
                        exclusions.append(value[1:])
                    else:
                        matchers.append(value)
                for value in exclusions:
                    if _matches_string(value, instance.name, case_sensitive=True):
                        return False

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

                for value in matchers:
                    if _matches_string(value, instance.name, case_sensitive=True):
                        break
                else:
                    return False

            case "tags":
                matchers = []
                exclusions = []
                for value in values:
                    if value.startswith("!"):
                        exclusions.append(value[1:])
                    else:
                        matchers.append(value)

                for value in exclusions:
                    for tag in instance.tags:
                        if _matches_string(value, tag):
                            return False

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

                for value in matchers:
                    if value == "*":  # in case instance.tags is empty
                        break
                    for tag in instance.tags:
                        if _matches_string(value, tag):
                            break
                    else:
                        continue
                    break
                else:
                    return False

            case "modloader":
                for value in values:
                    if _matches_string(value, instance.modloader):
                        break
                else:
                    return False

            case "minecraft":
                for value in values:
                    if any(
                        (
                            _matches_version(value, version)
                            for version in instance.minecraft_versions
                        )
                    ):
                        break
                else:
                    return False

            case "hosts":
                # this is handled at a higher level
                pass

            case _:
                raise NotImplementedError(
                    f"Don't know how to apply match condition {condition}."
                )
    return True

matches_host(hostname)

Determine whether the shulker box should be linked to from the current host machine

Returns:

Type Description
bool

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

Source code in enderchest/shulker_box.py
def matches_host(self, hostname: str):
    """Determine whether the shulker box should be linked to from the
    current host machine

    Returns
    -------
    bool
        True if the shulker box's hosts spec matches the host, False otherwise.
    """
    for condition, values in self.match_criteria:
        if condition == "hosts":
            if not any(
                fnmatch.fnmatchcase(hostname.lower(), host_spec.lower())
                for host_spec in values
            ):
                return False
    return True

write_to_cfg(config_file=None)

Write this box's configuration to INI

Parameters:

Name Type Description Default
config_file Path

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

None

Returns:

Type Description
str

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

Notes

The "root" attribute is ignored for this method

Source code in enderchest/shulker_box.py
def write_to_cfg(self, config_file: Path | None = None) -> str:
    """Write this box's configuration to INI

    Parameters
    ----------
    config_file : Path, optional
        The path to the config file, assuming you'd like to write the
        contents to file

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

    Notes
    -----
    The "root" attribute is ignored for this method
    """
    properties: dict[str, Any] = {"priority": self.priority}
    if self.max_link_depth != _DEFAULT_LINK_DEPTH:
        properties["max-link-depth"] = self.max_link_depth

    config = cfg.dumps(
        os.path.join(self.name, fs.SHULKER_BOX_CONFIG_NAME),
        properties,
        **dict(self.match_criteria),
        link_folders=self.link_folders,
        do_not_link=self.do_not_link,
    )

    if config_file:
        config_file.write_text(config)
    return config