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

161 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-18 08:48 +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 PEP 621 `[project]` metadata.""" 

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 PEP 621 `[project]` metadata.""" 

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 the PEP 621 readme file field.""" 

200 

201 plain = "text/plain" 

202 rst = "text/x-rst" 

203 markdown = "text/markdown" 

204 

205 

206class File(BaseModel): 

207 """File model for PEP 621 `[project]` metadata.""" 

208 

209 file: Path 

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

211 

212 

213class URLs(BaseModel): 

214 """URLs model for PEP 621 `[project]` metadata.""" 

215 

216 homepage: HttpUrlStr | None = None 

217 repository: HttpUrlStr | None = None 

218 documentation: HttpUrlStr | None = None 

219 changelog: HttpUrlStr | None = None 

220 

221 

222class Pep621Config(BaseModel): 

223 """PEP 621 `[project]` input model, as written by uv, hatchling, flit, PDM, setuptools and Poetry 2.x. 

224 

225 Required fields are name, and version and description unless listed as dynamic. 

226 """ 

227 

228 model_config = {"use_enum_values": True} 

229 

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

231 version: Annotated[ 

232 str | None, 

233 Field( 

234 default=None, 

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

236 ), 

237 ] 

238 description: str | None = None 

239 dynamic: Annotated[ 

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

241 ] 

242 

243 @model_validator(mode="after") 

244 def validate_required_unless_dynamic(self): 

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

246 dynamic = self.dynamic or [] 

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

248 raise ValueError( 

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

250 ) 

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

252 raise ValueError( 

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

254 ) 

255 return self 

256 

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

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

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

260 ) 

261 authors: list[STPerson] | None = None 

262 maintainers: list[STPerson] | None = None 

263 keywords: set[str] | None = None 

264 classifiers: list[str] | None = None 

265 urls: URLs | None = None 

266 

267 @field_validator("version") 

268 @classmethod 

269 def validate_version(cls, v): 

270 """Validate version using PEP 440.""" 

271 try: 

272 _ = parse_version(v) 

273 except ValueError as err: 

274 raise ValueError("Invalid version") from err 

275 return v 

276 

277 @field_validator("readme") 

278 @classmethod 

279 def validate_readme(cls, v): 

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

281 if isinstance(v, list): 

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

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

284 elif type(v) is File: 

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

286 raise ValueError("File does not exist") 

287 else: 

288 if not v.is_file(): 

289 raise ValueError("File does not exist") 

290 

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

292 @classmethod 

293 def validate_email_format(cls, v): 

294 """Validate email format.""" 

295 for person in v: 

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

297 raise ValueError("Invalid email format") 

298 return v