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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +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 PEP 621 `[project]` metadata."""
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 PEP 621 `[project]` metadata."""
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 the PEP 621 readme file field."""
201 plain = "text/plain"
202 rst = "text/x-rst"
203 markdown = "text/markdown"
206class File(BaseModel):
207 """File model for PEP 621 `[project]` metadata."""
209 file: Path
210 content_type: ContentTypeEnum | None = Field(alias="content-type")
213class URLs(BaseModel):
214 """URLs model for PEP 621 `[project]` metadata."""
216 homepage: HttpUrlStr | None = None
217 repository: HttpUrlStr | None = None
218 documentation: HttpUrlStr | None = None
219 changelog: HttpUrlStr | None = None
222class Pep621Config(BaseModel):
223 """PEP 621 `[project]` input model, as written by uv, hatchling, flit, PDM, setuptools and Poetry 2.x.
225 Required fields are name, and version and description unless listed as dynamic.
226 """
228 model_config = {"use_enum_values": True}
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 ]
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
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
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
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")
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