Coverage for gsb/manifest.py: 100%
47 statements
« prev ^ index » next coverage.py v7.2.6, created at 2024-09-08 16:23 -0400
« prev ^ index » next coverage.py v7.2.6, created at 2024-09-08 16:23 -0400
1"""Configuration definition for an individual GSB-managed save"""
2import datetime as dt
3import json
4import logging
5import tomllib
6from pathlib import Path
7from typing import Any, NamedTuple, Self, TypeAlias
9from ._version import get_versions
11LOGGER = logging.getLogger(__name__)
13MANIFEST_NAME = ".gsb_manifest"
15_ManifestDict: TypeAlias = dict[str, str | tuple[str, ...]]
18class Manifest(NamedTuple):
19 """Save-specific configuration
21 Attributes
22 ----------
23 root : Path
24 The directory containing the save / repo
25 name : str
26 The name / alias of the repo
27 patterns : tuple of str
28 The glob match-patterns that determine which files get tracked
29 """
31 root: Path
32 name: str
33 patterns: tuple[str, ...]
35 @classmethod
36 def of(cls, repo_root: Path) -> Self:
37 """Read the manifest of the specified GSB repo
39 Parameters
40 ----------
41 repo_root : Path
42 The root directory of the gsb-managed repo
44 Returns
45 -------
46 Manifest
47 the parsed manifest
49 Raises
50 ------
51 ValueError
52 If the configuration cannot be parsed
53 OSError
54 If the file does not exist or cannot otherwise be read
55 """
56 LOGGER.debug("Loading %s from %s", MANIFEST_NAME, repo_root)
57 as_dict: dict[str, Any] = {"root": repo_root}
58 contents: _ManifestDict = tomllib.loads((repo_root / MANIFEST_NAME).read_text())
59 for key, value in contents.items():
60 if key in Manifest._fields:
61 if isinstance(value, list):
62 value = tuple(value)
63 as_dict[key] = value
64 if "name" not in as_dict:
65 as_dict["name"] = repo_root.resolve().name
66 return cls(**as_dict)
68 def write(self) -> None:
69 """Write the manifest to file, overwriting any existing configuration
71 Returns
72 -------
73 None
75 Notes
76 -----
77 The location and name of this file is controlled by the `root` attribute
78 and the `MANIFEST_NAME` constant, respectively, and cannot be overridden
80 Raises
81 ------
82 OSError
83 If the destination folder (`root`) does not exist or cannot be
84 written to
85 """
86 as_dict = {
87 "generated_by_gsb": get_versions()["version"],
88 "last_modified": dt.datetime.now().isoformat(sep=" "),
89 }
90 for attribute, value in self._asdict().items(): # pylint: disable=no-member
91 # see: https://github.com/pylint-dev/pylint/issues/7891
92 if attribute == "root":
93 continue
94 as_dict[attribute] = value
96 as_toml = _to_toml(as_dict)
98 LOGGER.debug("Writing %s to %s", MANIFEST_NAME, self.root)
99 (self.root / MANIFEST_NAME).write_text(as_toml)
102def _to_toml(manifest: _ManifestDict) -> str:
103 """While Python 3.11 added native support for *parsing* TOML configurations,
104 it didn't include an API for *writing* them (this was an intentional part
105 of the PEP:
106 https://peps.python.org/pep-0680/#including-an-api-for-writing-toml).
108 Because the Manifest class is so simple, I'm rolling my own writer rather
109 than adding a dependency on a third-party library. That being said, I'm
110 abstracting that writer out in case I change my mind later. :D
112 Parameters
113 ----------
114 manifest : dict
115 A dict version of the manifest containing the entries that should be
116 written to file
118 Returns
119 -------
120 str
121 The manifest serialized as a TOML-compatible str
123 Notes
124 -----
125 This doesn't take an actual Manifest as a parameter so we can choose to
126 omit some attributes (`root`) and add others (versioning metadata)
127 """
128 dumped = ""
129 for key, value in manifest.items():
130 dumped += f"{key} = "
131 if isinstance(value, str):
132 # it's honestly shocking how often I rely on json.dump for str escaping
133 dumped += f"{json.dumps(value)}\n"
134 else:
135 dumped += "["
136 for entry in sorted(set(value)):
137 dumped += f"\n {json.dumps(entry)},"
138 dumped += "\n]\n"
139 return dumped