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

136 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-10 14:56 +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 ValidationError, 

15 field_validator, 

16 model_validator, 

17) 

18from typing_extensions import Annotated 

19 

20from somesy.core.models import LicenseEnum 

21from somesy.core.types import HttpUrlStr 

22 

23EMailAddress = TypeAdapter(EmailStr) 

24logger = getLogger("somesy") 

25 

26 

27class STPerson(BaseModel): 

28 """Person model for setuptools.""" 

29 

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

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

32 

33 

34class License(BaseModel): 

35 """License model for setuptools.""" 

36 

37 model_config = dict(validate_assignment=True) 

38 

39 file: Optional[Path] = None 

40 text: Optional[LicenseEnum] = None 

41 

42 @model_validator(mode="before") 

43 @classmethod 

44 def validate_xor(cls, values): 

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

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

47 if isinstance(values, str): 

48 if values in LicenseEnum.__members__: 

49 return {"text": values} 

50 else: 

51 raise ValueError("Invalid license.") 

52 if isinstance(values, list): 

53 # check if all elements are valid string for LicenseEnum 

54 for v in values: 

55 if not isinstance(v, str): 

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

57 if v not in LicenseEnum.__members__: 

58 raise ValueError("Invalid license.") 

59 return values 

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

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

62 return values 

63 

64 

65class PoetryConfig(BaseModel): 

66 """Poetry configuration model.""" 

67 

68 model_config = dict(use_enum_values=True) 

69 

70 name: Annotated[ 

71 str, 

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

73 ] 

74 version: Annotated[ 

75 str, 

76 Field( 

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

78 description="Package version", 

79 ), 

80 ] 

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

82 license: Annotated[ 

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

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

85 ] 

86 

87 # v1 has str, v2 has STPerson 

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

89 maintainers: Annotated[ 

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

91 ] = None 

92 

93 readme: Annotated[ 

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

95 ] = None 

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

97 None 

98 ) 

99 repository: Annotated[ 

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

101 ] = None 

102 documentation: Annotated[ 

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

104 ] = None 

105 keywords: Annotated[ 

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

107 ] = None 

108 classifiers: Annotated[ 

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

110 ] = None 

111 urls: Annotated[ 

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

113 ] = None 

114 

115 @field_validator("version") 

116 @classmethod 

117 def validate_version(cls, v): 

118 """Validate version using PEP 440.""" 

119 try: 

120 _ = parse_version(v) 

121 except ValueError as err: 

122 raise ValueError("Invalid version") from err 

123 return v 

124 

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

126 @classmethod 

127 def validate_email_format(cls, v): 

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

129 if v is None: 

130 return [] 

131 validated = [] 

132 for author in v: 

133 try: 

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

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

136 logger.warning( 

137 f"Invalid email format for author/maintainer {author}, omitting." 

138 ) 

139 else: 

140 validated.append(author) 

141 continue 

142 

143 if " " in author and EMailAddress.validate_python( 

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

145 ): 

146 validated.append(author) 

147 else: 

148 logger.warning( 

149 f"Invalid email format for author/maintainer {author}, omitting." 

150 ) 

151 except ValidationError: 

152 logger.warning( 

153 f"Invalid email format for author/maintainer {author}, omitting." 

154 ) 

155 return validated 

156 

157 @field_validator("readme") 

158 @classmethod 

159 def validate_readme(cls, v): 

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

161 if isinstance(v, list): 

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

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

164 else: 

165 if not v.is_file(): 

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

167 

168 

169class ContentTypeEnum(Enum): 

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

171 

172 plain = "text/plain" 

173 rst = "text/x-rst" 

174 markdown = "text/markdown" 

175 

176 

177class File(BaseModel): 

178 """File model for setuptools.""" 

179 

180 file: Path 

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

182 

183 

184class URLs(BaseModel): 

185 """URLs model for setuptools.""" 

186 

187 homepage: Optional[HttpUrlStr] = None 

188 repository: Optional[HttpUrlStr] = None 

189 documentation: Optional[HttpUrlStr] = None 

190 changelog: Optional[HttpUrlStr] = None 

191 

192 

193class SetuptoolsConfig(BaseModel): 

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

195 

196 model_config = dict(use_enum_values=True) 

197 

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

199 version: Annotated[ 

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

201 ] 

202 description: str 

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

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

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

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

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

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

209 urls: Optional[URLs] = None 

210 

211 @field_validator("version") 

212 @classmethod 

213 def validate_version(cls, v): 

214 """Validate version using PEP 440.""" 

215 try: 

216 _ = parse_version(v) 

217 except ValueError as err: 

218 raise ValueError("Invalid version") from err 

219 return v 

220 

221 @field_validator("readme") 

222 @classmethod 

223 def validate_readme(cls, v): 

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

225 if isinstance(v, list): 

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

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

228 elif type(v) is File: 

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

230 raise ValueError("File does not exist") 

231 else: 

232 if not v.is_file(): 

233 raise ValueError("File does not exist") 

234 

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

236 @classmethod 

237 def validate_email_format(cls, v): 

238 """Validate email format.""" 

239 for person in v: 

240 if person.email: 

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

242 raise ValueError("Invalid email format") 

243 return v