Coverage for src/somesy/pyproject/models.py: 79%

145 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-14 13:01 +0000

1"""Pyproject models.""" 

2 

3from enum import Enum 

4from logging import getLogger 

5from pathlib import Path 

6from typing import Dict, List, Optional, Set, Union 

7 

8from packaging.version import parse as parse_version 

9from pydantic import ( 

10 BaseModel, 

11 EmailStr, 

12 Field, 

13 TypeAdapter, 

14 field_validator, 

15 model_validator, 

16) 

17from typing_extensions import Annotated 

18 

19from somesy.core.models import LicenseEnum 

20from somesy.core.types import HttpUrlStr 

21 

22EMailAddress = TypeAdapter(EmailStr) 

23logger = getLogger("somesy") 

24 

25 

26class STPerson(BaseModel): 

27 """Person model for setuptools.""" 

28 

29 name: Annotated[str, Field(min_length=1)] 

30 email: Annotated[Optional[str], Field(min_length=1)] = None 

31 

32 def __str__(self): 

33 """Return string representation of STPerson.""" 

34 if self.email: 

35 return f"{self.name} <{self.email}>" 

36 else: 

37 return self.name 

38 

39 

40class License(BaseModel): 

41 """License model for setuptools.""" 

42 

43 model_config = dict(validate_assignment=True) 

44 

45 file: Optional[Path] = None 

46 text: Optional[LicenseEnum] = None 

47 

48 @model_validator(mode="before") 

49 @classmethod 

50 def validate_xor(cls, values): 

51 """Validate that only one of file or text is set.""" 

52 # check if this has just str or list of str 

53 if isinstance(values, str): 

54 if values in LicenseEnum.__members__: 

55 return {"text": values} 

56 else: 

57 raise ValueError("Invalid license.") 

58 if isinstance(values, list): 

59 # check if all elements are valid string for LicenseEnum 

60 for v in values: 

61 if not isinstance(v, str): 

62 raise ValueError("All elements must be strings.") 

63 if v not in LicenseEnum.__members__: 

64 raise ValueError("Invalid license.") 

65 return values 

66 if sum([bool(v) for v in values.values()]) != 1: 

67 raise ValueError("Either file or text must be set.") 

68 return values 

69 

70 

71class PoetryConfig(BaseModel): 

72 """Poetry configuration model.""" 

73 

74 model_config = dict(use_enum_values=True) 

75 

76 name: Annotated[ 

77 str, 

78 Field(pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$", description="Package name"), 

79 ] 

80 version: Annotated[ 

81 str, 

82 Field( 

83 pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$", 

84 description="Package version", 

85 ), 

86 ] 

87 description: Annotated[str, Field(description="Package description")] 

88 license: Annotated[ 

89 Optional[Union[LicenseEnum, List[LicenseEnum], License]], 

90 Field(description="An SPDX license identifier."), 

91 ] 

92 

93 # v1 has str, v2 has STPerson 

94 authors: Annotated[List[Union[str, STPerson]], Field(description="Package authors")] 

95 maintainers: Annotated[ 

96 Optional[List[Union[str, STPerson]]], Field(description="Package maintainers") 

97 ] = None 

98 

99 readme: Annotated[ 

100 Optional[Union[Path, List[Path]]], Field(description="Package readme file(s)") 

101 ] = None 

102 homepage: Annotated[Optional[HttpUrlStr], Field(description="Package homepage")] = ( 

103 None 

104 ) 

105 repository: Annotated[ 

106 Optional[HttpUrlStr], Field(description="Package repository") 

107 ] = None 

108 documentation: Annotated[ 

109 Optional[HttpUrlStr], Field(description="Package documentation page") 

110 ] = None 

111 keywords: Annotated[ 

112 Optional[Set[str]], Field(description="Keywords that describe the package") 

113 ] = None 

114 classifiers: Annotated[ 

115 Optional[List[str]], Field(description="pypi classifiers") 

116 ] = None 

117 urls: Annotated[ 

118 Optional[Dict[str, HttpUrlStr]], Field(description="Package URLs") 

119 ] = None 

120 

121 @field_validator("version") 

122 @classmethod 

123 def validate_version(cls, v): 

124 """Validate version using PEP 440.""" 

125 try: 

126 _ = parse_version(v) 

127 except ValueError as err: 

128 raise ValueError("Invalid version") from err 

129 return v 

130 

131 @field_validator("authors", "maintainers") 

132 @classmethod 

133 def validate_email_format(cls, v): 

134 """Validate person format, omit person that is not in correct format, don't raise an error.""" 

135 if v is None: 

136 return [] 

137 validated = [] 

138 seen = set() 

139 for author in v: 

140 if isinstance(author, STPerson) and author.email: 

141 if not EMailAddress.validate_python(author.email): 

142 logger.warning( 

143 f"Invalid email format for author/maintainer {author}." 

144 ) 

145 else: 

146 author_str = str(author) 

147 if author_str not in seen: 

148 seen.add(author_str) 

149 validated.append(author) 

150 else: 

151 logger.warning(f"Same person {author} is added multiple times.") 

152 elif "@" in author and EMailAddress.validate_python( 

153 author.split(" ")[-1][1:-1] 

154 ): 

155 validated.append(author) 

156 else: 

157 author_str = str(author) 

158 if author_str not in seen: 

159 seen.add(author_str) 

160 validated.append(author) 

161 else: 

162 logger.warning(f"Same person {author} is added multiple times.") 

163 

164 return validated 

165 

166 @field_validator("readme") 

167 @classmethod 

168 def validate_readme(cls, v): 

169 """Validate readme file(s) by checking whether files exist.""" 

170 if isinstance(v, list): 

171 if any(not e.is_file() for e in v): 

172 logger.warning("Some readme file(s) do not exist") 

173 else: 

174 if not v.is_file(): 

175 logger.warning("Readme file does not exist") 

176 

177 

178class ContentTypeEnum(Enum): 

179 """Content type enum for setuptools field file.""" 

180 

181 plain = "text/plain" 

182 rst = "text/x-rst" 

183 markdown = "text/markdown" 

184 

185 

186class File(BaseModel): 

187 """File model for setuptools.""" 

188 

189 file: Path 

190 content_type: Optional[ContentTypeEnum] = Field(alias="content-type") 

191 

192 

193class URLs(BaseModel): 

194 """URLs model for setuptools.""" 

195 

196 homepage: Optional[HttpUrlStr] = None 

197 repository: Optional[HttpUrlStr] = None 

198 documentation: Optional[HttpUrlStr] = None 

199 changelog: Optional[HttpUrlStr] = None 

200 

201 

202class SetuptoolsConfig(BaseModel): 

203 """Setuptools input model. Required fields are name, version, description, and requires_python.""" 

204 

205 model_config = dict(use_enum_values=True) 

206 

207 name: Annotated[str, Field(pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$")] 

208 version: Annotated[ 

209 str, Field(pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$") 

210 ] 

211 description: str 

212 readme: Optional[Union[Path, List[Path], File]] = None 

213 license: Optional[License] = Field(None, description="An SPDX license identifier.") 

214 authors: Optional[List[STPerson]] = None 

215 maintainers: Optional[List[STPerson]] = None 

216 keywords: Optional[Set[str]] = None 

217 classifiers: Optional[List[str]] = None 

218 urls: Optional[URLs] = None 

219 

220 @field_validator("version") 

221 @classmethod 

222 def validate_version(cls, v): 

223 """Validate version using PEP 440.""" 

224 try: 

225 _ = parse_version(v) 

226 except ValueError as err: 

227 raise ValueError("Invalid version") from err 

228 return v 

229 

230 @field_validator("readme") 

231 @classmethod 

232 def validate_readme(cls, v): 

233 """Validate readme file(s) by checking whether files exist.""" 

234 if isinstance(v, list): 

235 if any(not e.is_file() for e in v): 

236 raise ValueError("Some file(s) do not exist") 

237 elif type(v) is File: 

238 if not Path(v.file).is_file(): 

239 raise ValueError("File does not exist") 

240 else: 

241 if not v.is_file(): 

242 raise ValueError("File does not exist") 

243 

244 @field_validator("authors", "maintainers") 

245 @classmethod 

246 def validate_email_format(cls, v): 

247 """Validate email format.""" 

248 for person in v: 

249 if person.email: 

250 if not EMailAddress.validate_python(person.email): 

251 raise ValueError("Invalid email format") 

252 return v