Coverage for src/somesy/pyproject/writer.py: 93%

87 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-04-30 09:42 +0000

1"""Pyproject writers for setuptools and poetry.""" 

2import logging 

3from pathlib import Path 

4from typing import Any, List, Optional, Union 

5 

6import tomlkit 

7import wrapt 

8from rich.pretty import pretty_repr 

9from tomlkit import load 

10 

11from somesy.core.models import Person, ProjectMetadata 

12from somesy.core.writer import IgnoreKey, ProjectMetadataWriter 

13 

14from .models import PoetryConfig, SetuptoolsConfig 

15 

16logger = logging.getLogger("somesy") 

17 

18 

19class PyprojectCommon(ProjectMetadataWriter): 

20 """Poetry config file handler parsed from pyproject.toml.""" 

21 

22 def __init__( 

23 self, path: Path, *, section: List[str], model_cls, direct_mappings=None 

24 ): 

25 """Poetry config file handler parsed from pyproject.toml. 

26 

27 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

28 """ 

29 self._model_cls = model_cls 

30 self._section = section 

31 super().__init__( 

32 path, create_if_not_exists=False, direct_mappings=direct_mappings or {} 

33 ) 

34 

35 def _load(self) -> None: 

36 """Load pyproject.toml file.""" 

37 with open(self.path) as f: 

38 self._data = tomlkit.load(f) 

39 

40 def _validate(self) -> None: 

41 """Validate poetry config using pydantic class. 

42 

43 In order to preserve toml comments and structure, tomlkit library is used. 

44 Pydantic class only used for validation. 

45 """ 

46 config = dict(self._get_property([])) 

47 logger.debug( 

48 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}" 

49 ) 

50 self._model_cls(**config) 

51 

52 def save(self, path: Optional[Path] = None) -> None: 

53 """Save the pyproject file.""" 

54 path = path or self.path 

55 with open(path, "w") as f: 

56 tomlkit.dump(self._data, f) 

57 

58 def _get_property( 

59 self, key: Union[str, List[str]], *, remove: bool = False, **kwargs 

60 ) -> Optional[Any]: 

61 """Get a property from the pyproject.toml file.""" 

62 key_path = [key] if isinstance(key, str) else key 

63 full_path = self._section + key_path 

64 return super()._get_property(full_path, remove=remove, **kwargs) 

65 

66 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None: 

67 """Set a property in the pyproject.toml file.""" 

68 if isinstance(key, IgnoreKey): 

69 return 

70 key_path = [key] if isinstance(key, str) else key 

71 

72 if not value: # remove value and clean up the sub-dict 

73 self._get_property(key_path, remove=True) 

74 return 

75 

76 # get the tomlkit object of the section 

77 dat = self._get_property([]) 

78 

79 # dig down, create missing nested objects on the fly 

80 curr = dat 

81 for key in key_path[:-1]: 

82 if key not in curr: 

83 curr.add(key, tomlkit.table()) 

84 curr = curr[key] 

85 curr[key_path[-1]] = value 

86 

87 

88class Poetry(PyprojectCommon): 

89 """Poetry config file handler parsed from pyproject.toml.""" 

90 

91 def __init__(self, path: Path): 

92 """Poetry config file handler parsed from pyproject.toml. 

93 

94 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

95 """ 

96 super().__init__(path, section=["tool", "poetry"], model_cls=PoetryConfig) 

97 

98 @staticmethod 

99 def _from_person(person: Person): 

100 """Convert project metadata person object to poetry string for person format "full name <email>.""" 

101 return person.to_name_email_string() 

102 

103 @staticmethod 

104 def _to_person(person_obj: str) -> Person: 

105 """Parse poetry person string to a Person.""" 

106 return Person.from_name_email_string(person_obj) 

107 

108 

109class SetupTools(PyprojectCommon): 

110 """Setuptools config file handler parsed from setup.cfg.""" 

111 

112 def __init__(self, path: Path): 

113 """Setuptools config file handler parsed from pyproject.toml. 

114 

115 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

116 """ 

117 section = ["project"] 

118 mappings = { 

119 "homepage": ["urls", "homepage"], 

120 "repository": ["urls", "repository"], 

121 "documentation": ["urls", "documentation"], 

122 "license": ["license", "text"], 

123 } 

124 super().__init__( 

125 path, section=section, direct_mappings=mappings, model_cls=SetuptoolsConfig 

126 ) 

127 

128 @staticmethod 

129 def _from_person(person: Person): 

130 """Convert project metadata person object to setuptools dict for person format.""" 

131 return {"name": person.full_name, "email": person.email} 

132 

133 @staticmethod 

134 def _to_person(person_obj) -> Person: 

135 """Parse setuptools person string to a Person.""" 

136 # NOTE: for our purposes, does not matter what are given or family names, 

137 # we only compare on full_name anyway. 

138 names = list(map(lambda s: s.strip(), person_obj["name"].split())) 

139 return Person( 

140 **{ 

141 "given-names": " ".join(names[:-1]), 

142 "family-names": names[-1], 

143 "email": person_obj["email"].strip(), 

144 } 

145 ) 

146 

147 def sync(self, metadata: ProjectMetadata) -> None: 

148 """Sync metadata with pyproject.toml file and fix license field.""" 

149 super().sync(metadata) 

150 

151 # if license field has both text and file, remove file 

152 if ( 

153 self._get_property(["license", "file"]) is not None 

154 and self._get_property(["license", "text"]) is not None 

155 ): 

156 # delete license file property 

157 self._data["project"]["license"].pop("file") 

158 

159 

160# ---- 

161 

162 

163class Pyproject(wrapt.ObjectProxy): 

164 """Class for syncing pyproject file with other metadata files.""" 

165 

166 __wrapped__: Union[SetupTools, Poetry] 

167 

168 def __init__(self, path: Path): 

169 """Pyproject wrapper class. Wraps either setuptools or poetry. 

170 

171 Args: 

172 path (Path): Path to pyproject.toml file. 

173 

174 Raises: 

175 FileNotFoundError: Raised when pyproject.toml file is not found. 

176 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file. 

177 """ 

178 data = None 

179 if not path.is_file(): 

180 raise FileNotFoundError(f"pyproject file {path} not found") 

181 

182 with open(path, "r") as f: 

183 data = load(f) 

184 

185 # inspect file to pick suitable project metadata writer 

186 if "project" in data: 

187 logger.verbose("Found setuptools-based metadata in pyproject.toml") 

188 self.__wrapped__ = SetupTools(path) 

189 elif "tool" in data and "poetry" in data["tool"]: 

190 logger.verbose("Found poetry-based metadata in pyproject.toml") 

191 self.__wrapped__ = Poetry(path) 

192 else: 

193 msg = "The pyproject.toml file is ambiguous, either add a [project] or [tool.poetry] section" 

194 raise ValueError(msg) 

195 

196 super().__init__(self.__wrapped__)