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

161 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-04 11:35 +0000

1"""Pyproject models.""" 

2 

3from enum import Enum 

4from logging import getLogger 

5from pathlib import Path 

6from typing import Annotated 

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) 

17 

18from somesy.core.models import LicenseEnum 

19from somesy.core.types import HttpUrlStr 

20 

21EMailAddress = TypeAdapter(EmailStr) 

22logger = getLogger("somesy") 

23 

24 

25class STPerson(BaseModel): 

26 """Person model for setuptools.""" 

27 

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

29 email: Annotated[str | None, Field(min_length=1)] = None 

30 

31 def __str__(self): 

32 """Return string representation of STPerson.""" 

33 if self.email: 

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

35 else: 

36 return self.name 

37 

38 

39class License(BaseModel): 

40 """License model for setuptools.""" 

41 

42 model_config = {"validate_assignment": True} 

43 

44 file: Path | None = None 

45 text: LicenseEnum | None = None 

46 

47 @model_validator(mode="before") 

48 @classmethod 

49 def validate_xor(cls, values): 

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

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

52 if isinstance(values, str): 

53 if values in LicenseEnum.__members__: 

54 return {"text": values} 

55 else: 

56 raise ValueError("Invalid license.") 

57 if isinstance(values, list): 

58 # check if all elements are valid string for LicenseEnum 

59 for v in values: 

60 if not isinstance(v, str): 

61 raise TypeError("All elements must be strings.") 

62 if v not in LicenseEnum.__members__: 

63 raise ValueError("Invalid license.") 

64 return values 

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

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

67 return values 

68 

69 

70class PoetryConfig(BaseModel): 

71 """Poetry configuration model.""" 

72 

73 model_config = {"use_enum_values": True} 

74 

75 name: Annotated[ 

76 str, 

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

78 ] 

79 version: Annotated[ 

80 str | None, 

81 Field( 

82 default=None, 

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

84 description="Package version", 

85 ), 

86 ] 

87 description: Annotated[ 

88 str | None, Field(default=None, description="Package description") 

89 ] 

90 dynamic: Annotated[ 

91 list[str] | None, Field(default=None, description="PEP 621 dynamic fields") 

92 ] 

93 

94 @model_validator(mode="after") 

95 def validate_required_unless_dynamic(self): 

96 """Validate that version and description are present unless listed in dynamic.""" 

97 dynamic = self.dynamic or [] 

98 if self.version is None and "version" not in dynamic: 

99 raise ValueError( 

100 "Field 'version' is required when not listed in 'dynamic'." 

101 ) 

102 if self.description is None and "description" not in dynamic: 

103 raise ValueError( 

104 "Field 'description' is required when not listed in 'dynamic'." 

105 ) 

106 return self 

107 

108 license: Annotated[ 

109 LicenseEnum | list[LicenseEnum] | License | str | None, 

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

111 ] 

112 

113 # v1 has str, v2 has STPerson 

114 authors: Annotated[list[str | STPerson], Field(description="Package authors")] 

115 maintainers: Annotated[ 

116 list[str | STPerson] | None, Field(description="Package maintainers") 

117 ] = None 

118 

119 readme: Annotated[ 

120 Path | list[Path] | None, Field(description="Package readme file(s)") 

121 ] = None 

122 homepage: Annotated[HttpUrlStr | None, Field(description="Package homepage")] = None 

123 repository: Annotated[ 

124 HttpUrlStr | None, Field(description="Package repository") 

125 ] = None 

126 documentation: Annotated[ 

127 HttpUrlStr | None, Field(description="Package documentation page") 

128 ] = None 

129 keywords: Annotated[ 

130 set[str] | None, Field(description="Keywords that describe the package") 

131 ] = None 

132 classifiers: Annotated[list[str] | None, Field(description="pypi classifiers")] = ( 

133 None 

134 ) 

135 urls: Annotated[dict[str, HttpUrlStr] | None, Field(description="Package URLs")] = ( 

136 None 

137 ) 

138 

139 @field_validator("version") 

140 @classmethod 

141 def validate_version(cls, v): 

142 """Validate version using PEP 440.""" 

143 try: 

144 _ = parse_version(v) 

145 except ValueError as err: 

146 raise ValueError("Invalid version") from err 

147 return v 

148 

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

150 @classmethod 

151 def validate_email_format(cls, v): 

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

153 if v is None: 

154 return [] 

155 validated = [] 

156 seen = set() 

157 for author in v: 

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

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

160 logger.warning( 

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

162 ) 

163 else: 

164 author_str = str(author) 

165 if author_str not in seen: 

166 seen.add(author_str) 

167 validated.append(author) 

168 else: 

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

170 elif ( 

171 isinstance(author, str) 

172 and "@" in author 

173 and EMailAddress.validate_python(author.split(" ")[-1][1:-1]) 

174 ): 

175 validated.append(author) 

176 else: 

177 author_str = str(author) 

178 if author_str not in seen: 

179 seen.add(author_str) 

180 validated.append(author) 

181 else: 

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

183 

184 return validated 

185 

186 @field_validator("readme") 

187 @classmethod 

188 def validate_readme(cls, v): 

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

190 if isinstance(v, list): 

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

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

193 else: 

194 if not v.is_file(): 

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

196 

197 

198class ContentTypeEnum(Enum): 

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

200 

201 plain = "text/plain" 

202 rst = "text/x-rst" 

203 markdown = "text/markdown" 

204 

205 

206class File(BaseModel): 

207 """File model for setuptools.""" 

208 

209 file: Path 

210 content_type: ContentTypeEnum | None = Field(alias="content-type") 

211 

212 

213class URLs(BaseModel): 

214 """URLs model for setuptools.""" 

215 

216 homepage: HttpUrlStr | None = None 

217 repository: HttpUrlStr | None = None 

218 documentation: HttpUrlStr | None = None 

219 changelog: HttpUrlStr | None = None 

220 

221 

222class SetuptoolsConfig(BaseModel): 

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

224 

225 model_config = {"use_enum_values": True} 

226 

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

228 version: Annotated[ 

229 str | None, 

230 Field( 

231 default=None, 

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

233 ), 

234 ] 

235 description: str | None = None 

236 dynamic: Annotated[ 

237 list[str] | None, Field(default=None, description="PEP 621 dynamic fields") 

238 ] 

239 

240 @model_validator(mode="after") 

241 def validate_required_unless_dynamic(self): 

242 """Validate that version and description are present unless listed in dynamic.""" 

243 dynamic = self.dynamic or [] 

244 if self.version is None and "version" not in dynamic: 

245 raise ValueError( 

246 "Field 'version' is required when not listed in 'dynamic'." 

247 ) 

248 if self.description is None and "description" not in dynamic: 

249 raise ValueError( 

250 "Field 'description' is required when not listed in 'dynamic'." 

251 ) 

252 return self 

253 

254 readme: Path | list[Path] | File | None = None 

255 license: License | LicenseEnum | str | None = Field( 

256 None, description="An SPDX license identifier." 

257 ) 

258 authors: list[STPerson] | None = None 

259 maintainers: list[STPerson] | None = None 

260 keywords: set[str] | None = None 

261 classifiers: list[str] | None = None 

262 urls: URLs | None = None 

263 

264 @field_validator("version") 

265 @classmethod 

266 def validate_version(cls, v): 

267 """Validate version using PEP 440.""" 

268 try: 

269 _ = parse_version(v) 

270 except ValueError as err: 

271 raise ValueError("Invalid version") from err 

272 return v 

273 

274 @field_validator("readme") 

275 @classmethod 

276 def validate_readme(cls, v): 

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

278 if isinstance(v, list): 

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

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

281 elif type(v) is File: 

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

283 raise ValueError("File does not exist") 

284 else: 

285 if not v.is_file(): 

286 raise ValueError("File does not exist") 

287 

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

289 @classmethod 

290 def validate_email_format(cls, v): 

291 """Validate email format.""" 

292 for person in v: 

293 if person.email and not EMailAddress.validate_python(person.email): 

294 raise ValueError("Invalid email format") 

295 return v