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

91 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-07-29 07:50 +0000

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

2 

3import logging 

4from pathlib import Path 

5from typing import Any, List, Optional, Union 

6 

7import tomlkit 

8import wrapt 

9from rich.pretty import pretty_repr 

10from tomlkit import load 

11 

12from somesy.core.models import Person, ProjectMetadata 

13from somesy.core.writer import IgnoreKey, ProjectMetadataWriter 

14 

15from .models import PoetryConfig, SetuptoolsConfig 

16 

17logger = logging.getLogger("somesy") 

18 

19 

20class PyprojectCommon(ProjectMetadataWriter): 

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

22 

23 def __init__( 

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

25 ): 

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

27 

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

29 """ 

30 self._model_cls = model_cls 

31 self._section = section 

32 super().__init__( 

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

34 ) 

35 

36 def _load(self) -> None: 

37 """Load pyproject.toml file.""" 

38 with open(self.path) as f: 

39 self._data = tomlkit.load(f) 

40 

41 def _validate(self) -> None: 

42 """Validate poetry config using pydantic class. 

43 

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

45 Pydantic class only used for validation. 

46 """ 

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

48 logger.debug( 

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

50 ) 

51 self._model_cls(**config) 

52 

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

54 """Save the pyproject file.""" 

55 path = path or self.path 

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

57 tomlkit.dump(self._data, f) 

58 

59 def _get_property( 

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

61 ) -> Optional[Any]: 

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

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

64 full_path = self._section + key_path 

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

66 

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

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

69 if isinstance(key, IgnoreKey): 

70 return 

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

72 

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

74 self._get_property(key_path, remove=True) 

75 return 

76 

77 # get the tomlkit object of the section 

78 dat = self._get_property([]) 

79 

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

81 curr = dat 

82 for key in key_path[:-1]: 

83 if key not in curr: 

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

85 curr = curr[key] 

86 curr[key_path[-1]] = value 

87 

88 

89class Poetry(PyprojectCommon): 

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

91 

92 def __init__(self, path: Path): 

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

94 

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

96 """ 

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

98 

99 @staticmethod 

100 def _from_person(person: Person): 

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

102 return person.to_name_email_string() 

103 

104 @staticmethod 

105 def _to_person(person_obj: str) -> Optional[Person]: 

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

107 try: 

108 return Person.from_name_email_string(person_obj) 

109 except (ValueError, AttributeError): 

110 logger.warning(f"Cannot convert {person_obj} to Person object.") 

111 return None 

112 

113 

114class SetupTools(PyprojectCommon): 

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

116 

117 def __init__(self, path: Path): 

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

119 

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

121 """ 

122 section = ["project"] 

123 mappings = { 

124 "homepage": ["urls", "homepage"], 

125 "repository": ["urls", "repository"], 

126 "documentation": ["urls", "documentation"], 

127 "license": ["license", "text"], 

128 } 

129 super().__init__( 

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

131 ) 

132 

133 @staticmethod 

134 def _from_person(person: Person): 

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

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

137 

138 @staticmethod 

139 def _to_person(person_obj) -> Person: 

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

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

142 # we only compare on full_name anyway. 

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

144 return Person( 

145 **{ 

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

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

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

149 } 

150 ) 

151 

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

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

154 super().sync(metadata) 

155 

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

157 if ( 

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

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

160 ): 

161 # delete license file property 

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

163 

164 

165# ---- 

166 

167 

168class Pyproject(wrapt.ObjectProxy): 

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

170 

171 __wrapped__: Union[SetupTools, Poetry] 

172 

173 def __init__(self, path: Path): 

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

175 

176 Args: 

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

178 

179 Raises: 

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

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

182 

183 """ 

184 data = None 

185 if not path.is_file(): 

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

187 

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

189 data = load(f) 

190 

191 # inspect file to pick suitable project metadata writer 

192 if "project" in data: 

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

194 self.__wrapped__ = SetupTools(path) 

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

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

197 self.__wrapped__ = Poetry(path) 

198 else: 

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

200 raise ValueError(msg) 

201 

202 super().__init__(self.__wrapped__)