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

162 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 10:55 +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# PEP 508 distribution name: ASCII letter/digit endpoints, with any run of 

25# letters, digits, dots, underscores or hyphens allowed in between. 

26_PACKAGE_NAME_PATTERN = r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$" 

27 

28 

29class STPerson(BaseModel): 

30 """Person model for PEP 621 `[project]` metadata.""" 

31 

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

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

34 

35 def __str__(self): 

36 """Return string representation of STPerson.""" 

37 if self.email: 

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

39 else: 

40 return self.name 

41 

42 

43class License(BaseModel): 

44 """License model for PEP 621 `[project]` metadata.""" 

45 

46 model_config = {"validate_assignment": True} 

47 

48 file: Path | None = None 

49 text: LicenseEnum | None = None 

50 

51 @model_validator(mode="before") 

52 @classmethod 

53 def validate_xor(cls, values): 

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

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

56 if isinstance(values, str): 

57 if values in LicenseEnum.__members__: 

58 return {"text": values} 

59 else: 

60 raise ValueError("Invalid license.") 

61 if isinstance(values, list): 

62 # check if all elements are valid string for LicenseEnum 

63 for v in values: 

64 if not isinstance(v, str): 

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

66 if v not in LicenseEnum.__members__: 

67 raise ValueError("Invalid license.") 

68 return values 

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

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

71 return values 

72 

73 

74class PoetryConfig(BaseModel): 

75 """Poetry configuration model.""" 

76 

77 model_config = {"use_enum_values": True} 

78 

79 name: Annotated[ 

80 str, 

81 Field(pattern=_PACKAGE_NAME_PATTERN, description="Package name"), 

82 ] 

83 version: Annotated[ 

84 str | None, 

85 Field( 

86 default=None, 

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

88 description="Package version", 

89 ), 

90 ] 

91 description: Annotated[ 

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

93 ] 

94 dynamic: Annotated[ 

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

96 ] 

97 

98 @model_validator(mode="after") 

99 def validate_required_unless_dynamic(self): 

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

101 dynamic = self.dynamic or [] 

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

103 raise ValueError( 

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

105 ) 

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

107 raise ValueError( 

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

109 ) 

110 return self 

111 

112 license: Annotated[ 

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

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

115 ] 

116 

117 # v1 has str, v2 has STPerson 

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

119 maintainers: Annotated[ 

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

121 ] = None 

122 

123 readme: Annotated[ 

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

125 ] = None 

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

127 repository: Annotated[ 

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

129 ] = None 

130 documentation: Annotated[ 

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

132 ] = None 

133 keywords: Annotated[ 

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

135 ] = None 

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

137 None 

138 ) 

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

140 None 

141 ) 

142 

143 @field_validator("version") 

144 @classmethod 

145 def validate_version(cls, v): 

146 """Validate version using PEP 440.""" 

147 try: 

148 _ = parse_version(v) 

149 except ValueError as err: 

150 raise ValueError("Invalid version") from err 

151 return v 

152 

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

154 @classmethod 

155 def validate_email_format(cls, v): 

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

157 if v is None: 

158 return [] 

159 validated = [] 

160 seen = set() 

161 for author in v: 

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

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

164 logger.warning( 

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

166 ) 

167 else: 

168 author_str = str(author) 

169 if author_str not in seen: 

170 seen.add(author_str) 

171 validated.append(author) 

172 else: 

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

174 elif ( 

175 isinstance(author, str) 

176 and "@" in author 

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

178 ): 

179 validated.append(author) 

180 else: 

181 author_str = str(author) 

182 if author_str not in seen: 

183 seen.add(author_str) 

184 validated.append(author) 

185 else: 

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

187 

188 return validated 

189 

190 @field_validator("readme") 

191 @classmethod 

192 def validate_readme(cls, v): 

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

194 if isinstance(v, list): 

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

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

197 else: 

198 if not v.is_file(): 

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

200 

201 

202class ContentTypeEnum(Enum): 

203 """Content type enum for the PEP 621 readme file field.""" 

204 

205 plain = "text/plain" 

206 rst = "text/x-rst" 

207 markdown = "text/markdown" 

208 

209 

210class File(BaseModel): 

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

212 

213 file: Path 

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

215 

216 

217class URLs(BaseModel): 

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

219 

220 homepage: HttpUrlStr | None = None 

221 repository: HttpUrlStr | None = None 

222 documentation: HttpUrlStr | None = None 

223 changelog: HttpUrlStr | None = None 

224 

225 

226class Pep621Config(BaseModel): 

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

228 

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

230 """ 

231 

232 model_config = {"use_enum_values": True} 

233 

234 name: Annotated[str, Field(pattern=_PACKAGE_NAME_PATTERN)] 

235 version: Annotated[ 

236 str | None, 

237 Field( 

238 default=None, 

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

240 ), 

241 ] 

242 description: str | None = None 

243 dynamic: Annotated[ 

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

245 ] 

246 

247 @model_validator(mode="after") 

248 def validate_required_unless_dynamic(self): 

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

250 dynamic = self.dynamic or [] 

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

252 raise ValueError( 

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

254 ) 

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

256 raise ValueError( 

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

258 ) 

259 return self 

260 

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

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

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

264 ) 

265 authors: list[STPerson] | None = None 

266 maintainers: list[STPerson] | None = None 

267 keywords: set[str] | None = None 

268 classifiers: list[str] | None = None 

269 urls: URLs | None = None 

270 

271 @field_validator("version") 

272 @classmethod 

273 def validate_version(cls, v): 

274 """Validate version using PEP 440.""" 

275 try: 

276 _ = parse_version(v) 

277 except ValueError as err: 

278 raise ValueError("Invalid version") from err 

279 return v 

280 

281 @field_validator("readme") 

282 @classmethod 

283 def validate_readme(cls, v): 

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

285 if isinstance(v, list): 

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

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

288 elif type(v) is File: 

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

290 raise ValueError("File does not exist") 

291 else: 

292 if not v.is_file(): 

293 raise ValueError("File does not exist") 

294 

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

296 @classmethod 

297 def validate_email_format(cls, v): 

298 """Validate email format.""" 

299 for person in v: 

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

301 raise ValueError("Invalid email format") 

302 return v