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

153 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:13 +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 

29def _check_version(v): 

30 """Validate version using PEP 440.""" 

31 try: 

32 _ = parse_version(v) 

33 except ValueError as err: 

34 raise ValueError("Invalid version") from err 

35 return v 

36 

37 

38def _check_required_unless_dynamic(model): 

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

40 dynamic = model.dynamic or [] 

41 if model.version is None and "version" not in dynamic: 

42 raise ValueError("Field 'version' is required when not listed in 'dynamic'.") 

43 if model.description is None and "description" not in dynamic: 

44 raise ValueError( 

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

46 ) 

47 return model 

48 

49 

50class STPerson(BaseModel): 

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

52 

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

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

55 

56 def __str__(self): 

57 """Return string representation of STPerson.""" 

58 if self.email: 

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

60 else: 

61 return self.name 

62 

63 

64class License(BaseModel): 

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

66 

67 model_config = {"validate_assignment": True} 

68 

69 file: Path | None = None 

70 text: LicenseEnum | None = None 

71 

72 @model_validator(mode="before") 

73 @classmethod 

74 def validate_xor(cls, values): 

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

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

77 if isinstance(values, str): 

78 if values in LicenseEnum.__members__: 

79 return {"text": values} 

80 else: 

81 raise ValueError("Invalid license.") 

82 if isinstance(values, list): 

83 # check if all elements are valid string for LicenseEnum 

84 for v in values: 

85 if not isinstance(v, str): 

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

87 if v not in LicenseEnum.__members__: 

88 raise ValueError("Invalid license.") 

89 return values 

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

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

92 return values 

93 

94 

95class PoetryConfig(BaseModel): 

96 """Poetry configuration model.""" 

97 

98 model_config = {"use_enum_values": True} 

99 

100 name: Annotated[ 

101 str, 

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

103 ] 

104 version: Annotated[ 

105 str | None, 

106 Field( 

107 default=None, 

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

109 description="Package version", 

110 ), 

111 ] 

112 description: Annotated[ 

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

114 ] 

115 dynamic: Annotated[ 

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

117 ] 

118 

119 @model_validator(mode="after") 

120 def validate_required_unless_dynamic(self): 

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

122 return _check_required_unless_dynamic(self) 

123 

124 license: Annotated[ 

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

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

127 ] = None 

128 

129 # v1 has str, v2 has STPerson 

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

131 maintainers: Annotated[ 

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

133 ] = None 

134 

135 readme: Annotated[ 

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

137 ] = None 

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

139 repository: Annotated[ 

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

141 ] = None 

142 documentation: Annotated[ 

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

144 ] = None 

145 keywords: Annotated[ 

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

147 ] = None 

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

149 None 

150 ) 

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

152 None 

153 ) 

154 

155 @field_validator("version") 

156 @classmethod 

157 def validate_version(cls, v): 

158 """Validate version using PEP 440.""" 

159 return _check_version(v) 

160 

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

162 @classmethod 

163 def validate_email_format(cls, v): 

164 """Validate person format, raise on invalid emails and drop duplicate persons.""" 

165 if v is None: 

166 return [] 

167 validated = [] 

168 seen = set() 

169 for author in v: 

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

171 EMailAddress.validate_python(author.email) 

172 elif isinstance(author, str) and "@" in author: 

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

174 author_str = str(author) 

175 if author_str not in seen: 

176 seen.add(author_str) 

177 validated.append(author) 

178 else: 

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

180 

181 return validated 

182 

183 @field_validator("readme") 

184 @classmethod 

185 def validate_readme(cls, v): 

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

187 if isinstance(v, list): 

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

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

190 else: 

191 if not v.is_file(): 

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

193 return v 

194 

195 

196class ContentTypeEnum(Enum): 

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

198 

199 plain = "text/plain" 

200 rst = "text/x-rst" 

201 markdown = "text/markdown" 

202 

203 

204class File(BaseModel): 

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

206 

207 file: Path 

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

209 

210 

211class URLs(BaseModel): 

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

213 

214 homepage: HttpUrlStr | None = None 

215 repository: HttpUrlStr | None = None 

216 documentation: HttpUrlStr | None = None 

217 changelog: HttpUrlStr | None = None 

218 

219 

220class Pep621Config(BaseModel): 

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

222 

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

224 """ 

225 

226 model_config = {"use_enum_values": True} 

227 

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

229 version: Annotated[ 

230 str | None, 

231 Field( 

232 default=None, 

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

234 ), 

235 ] 

236 description: str | None = None 

237 dynamic: Annotated[ 

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

239 ] 

240 

241 @model_validator(mode="after") 

242 def validate_required_unless_dynamic(self): 

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

244 return _check_required_unless_dynamic(self) 

245 

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

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

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

249 ) 

250 authors: list[STPerson] | None = None 

251 maintainers: list[STPerson] | None = None 

252 keywords: set[str] | None = None 

253 classifiers: list[str] | None = None 

254 urls: URLs | None = None 

255 

256 @field_validator("version") 

257 @classmethod 

258 def validate_version(cls, v): 

259 """Validate version using PEP 440.""" 

260 return _check_version(v) 

261 

262 @field_validator("readme") 

263 @classmethod 

264 def validate_readme(cls, v): 

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

266 if isinstance(v, list): 

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

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

269 elif type(v) is File: 

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

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

272 else: 

273 if not v.is_file(): 

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

275 return v 

276 

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

278 @classmethod 

279 def validate_email_format(cls, v): 

280 """Validate email format.""" 

281 for person in v: 

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

283 raise ValueError("Invalid email format") 

284 return v