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

81 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-10 14:33 +0000

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

2import logging 

3import re 

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 

13from somesy.core.writer import 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(self, key: Union[str, List[str]]) -> Optional[Any]: 

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

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

62 full_path = self._section + key_path 

63 return super()._get_property(full_path) 

64 

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

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

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

68 # get the tomlkit object of the section 

69 dat = self._get_property([]) 

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

71 curr = dat 

72 for key in key_path[:-1]: 

73 if key not in curr: 

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

75 curr = curr[key] 

76 curr[key_path[-1]] = value 

77 

78 

79class Poetry(PyprojectCommon): 

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

81 

82 def __init__(self, path: Path): 

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

84 

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

86 """ 

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

88 

89 @staticmethod 

90 def _from_person(person: Person): 

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

92 return f"{person.full_name} <{person.email}>" 

93 

94 @staticmethod 

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

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

97 m = re.match(r"\s*([^<]+)<([^>]+)>", person_obj) 

98 names, mail = ( 

99 list(map(lambda s: s.strip(), m.group(1).split())), 

100 m.group(2).strip(), 

101 ) 

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

103 # we only compare on full_name anyway. 

104 return Person( 

105 **{ 

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

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

108 "email": mail, 

109 } 

110 ) 

111 

112 

113class SetupTools(PyprojectCommon): 

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

115 

116 def __init__(self, path: Path): 

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

118 

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

120 """ 

121 section = ["project"] 

122 mappings = { 

123 "homepage": ["urls", "homepage"], 

124 "repository": ["urls", "repository"], 

125 } 

126 super().__init__( 

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

128 ) 

129 

130 @staticmethod 

131 def _from_person(person: Person): 

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

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

134 

135 @staticmethod 

136 def _to_person(person_obj) -> Person: 

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

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

139 # we only compare on full_name anyway. 

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

141 return Person( 

142 **{ 

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

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

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

146 } 

147 ) 

148 

149 

150# ---- 

151 

152 

153class Pyproject(wrapt.ObjectProxy): 

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

155 

156 __wrapped__: Union[SetupTools, Poetry] 

157 

158 def __init__(self, path: Path): 

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

160 

161 Args: 

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

163 

164 Raises: 

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

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

167 """ 

168 data = None 

169 if not path.is_file(): 

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

171 

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

173 data = load(f) 

174 

175 # inspect file to pick suitable project metadata writer 

176 if "project" in data: 

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

178 self.__wrapped__ = SetupTools(path) 

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

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

181 self.__wrapped__ = Poetry(path) 

182 else: 

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

184 raise ValueError(msg) 

185 

186 super().__init__(self.__wrapped__)