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