Coverage for gsb/manifest.py: 100%

47 statements  

« 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 

8 

9from ._version import get_versions 

10 

11LOGGER = logging.getLogger(__name__) 

12 

13MANIFEST_NAME = ".gsb_manifest" 

14 

15_ManifestDict: TypeAlias = dict[str, str | tuple[str, ...]] 

16 

17 

18class Manifest(NamedTuple): 

19 """Save-specific configuration 

20 

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 """ 

30 

31 root: Path 

32 name: str 

33 patterns: tuple[str, ...] 

34 

35 @classmethod 

36 def of(cls, repo_root: Path) -> Self: 

37 """Read the manifest of the specified GSB repo 

38 

39 Parameters 

40 ---------- 

41 repo_root : Path 

42 The root directory of the gsb-managed repo 

43 

44 Returns 

45 ------- 

46 Manifest 

47 the parsed manifest 

48 

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) 

67 

68 def write(self) -> None: 

69 """Write the manifest to file, overwriting any existing configuration 

70 

71 Returns 

72 ------- 

73 None 

74 

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 

79 

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 

95 

96 as_toml = _to_toml(as_dict) 

97 

98 LOGGER.debug("Writing %s to %s", MANIFEST_NAME, self.root) 

99 (self.root / MANIFEST_NAME).write_text(as_toml) 

100 

101 

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). 

107 

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 

111 

112 Parameters 

113 ---------- 

114 manifest : dict 

115 A dict version of the manifest containing the entries that should be 

116 written to file 

117 

118 Returns 

119 ------- 

120 str 

121 The manifest serialized as a TOML-compatible str 

122 

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