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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
1"""Pyproject models."""
3from enum import Enum
4from logging import getLogger
5from pathlib import Path
6from typing import Annotated
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)
18from somesy.core.models import LicenseEnum
19from somesy.core.types import HttpUrlStr
21EMailAddress = TypeAdapter(EmailStr)
22logger = getLogger("somesy")
25class STPerson(BaseModel):
26 """Person model for setuptools."""
28 name: Annotated[str, Field(min_length=1)]
29 email: Annotated[str | None, Field(min_length=1)] = None
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
39class License(BaseModel):
40 """License model for setuptools."""
42 model_config = {"validate_assignment": True}
44 file: Path | None = None
45 text: LicenseEnum | None = None
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
70class PoetryConfig(BaseModel):
71 """Poetry configuration model."""
73 model_config = {"use_enum_values": True}
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 ]
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
108 license: Annotated[
109 LicenseEnum | list[LicenseEnum] | License | str | None,
110 Field(description="An SPDX license identifier."),
111 ]
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
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 )
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
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.")
184 return validated
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")
198class ContentTypeEnum(Enum):
199 """Content type enum for setuptools field file."""
201 plain = "text/plain"
202 rst = "text/x-rst"
203 markdown = "text/markdown"
206class File(BaseModel):
207 """File model for setuptools."""
209 file: Path
210 content_type: ContentTypeEnum | None = Field(alias="content-type")
213class URLs(BaseModel):
214 """URLs model for setuptools."""
216 homepage: HttpUrlStr | None = None
217 repository: HttpUrlStr | None = None
218 documentation: HttpUrlStr | None = None
219 changelog: HttpUrlStr | None = None
222class SetuptoolsConfig(BaseModel):
223 """Setuptools input model. Required fields are name, version, description, and requires_python."""
225 model_config = {"use_enum_values": True}
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 ]
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
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
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
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")
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