Coverage for gsb/manifest.py: 100%

47 statements  

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

2 

3import datetime as dt 

4import json 

5import logging 

6import tomllib 

7from pathlib import Path 

8from typing import Any, NamedTuple, Self, TypeAlias 

9 

10from ._version import get_versions 

11 

12LOGGER = logging.getLogger(__name__) 

13 

14MANIFEST_NAME = ".gsb_manifest" 

15 

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

17 

18 

19class Manifest(NamedTuple): 

20 """Save-specific configuration 

21 

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

31 

32 root: Path 

33 name: str 

34 patterns: tuple[str, ...] 

35 

36 @classmethod 

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

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

39 

40 Parameters 

41 ---------- 

42 repo_root : Path 

43 The root directory of the gsb-managed repo 

44 

45 Returns 

46 ------- 

47 Manifest 

48 the parsed manifest 

49 

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) 

68 

69 def write(self) -> None: 

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

71 

72 Returns 

73 ------- 

74 None 

75 

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 

80 

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 

96 

97 as_toml = _to_toml(as_dict) 

98 

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

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

101 

102 

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

108 

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 

112 

113 Parameters 

114 ---------- 

115 manifest : dict 

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

117 written to file 

118 

119 Returns 

120 ------- 

121 str 

122 The manifest serialized as a TOML-compatible str 

123 

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