Coverage for src/somesy/pyproject/models.py: 81%
162 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 10:45 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 10:45 +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")
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])?$"
29class STPerson(BaseModel):
30 """Person model for PEP 621 `[project]` metadata."""
32 name: Annotated[str, Field(min_length=1)]
33 email: Annotated[str | None, Field(min_length=1)] = None
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
43class License(BaseModel):
44 """License model for PEP 621 `[project]` metadata."""
46 model_config = {"validate_assignment": True}
48 file: Path | None = None
49 text: LicenseEnum | None = None
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
74class PoetryConfig(BaseModel):
75 """Poetry configuration model."""
77 model_config = {"use_enum_values": True}
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 ]
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
112 license: Annotated[
113 LicenseEnum | list[LicenseEnum] | License | str | None,
114 Field(description="An SPDX license identifier."),
115 ]
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
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 )
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
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.")
188 return validated
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")
202class ContentTypeEnum(Enum):
203 """Content type enum for the PEP 621 readme file field."""
205 plain = "text/plain"
206 rst = "text/x-rst"
207 markdown = "text/markdown"
210class File(BaseModel):
211 """File model for PEP 621 `[project]` metadata."""
213 file: Path
214 content_type: ContentTypeEnum | None = Field(alias="content-type")
217class URLs(BaseModel):
218 """URLs model for PEP 621 `[project]` metadata."""
220 homepage: HttpUrlStr | None = None
221 repository: HttpUrlStr | None = None
222 documentation: HttpUrlStr | None = None
223 changelog: HttpUrlStr | None = None
226class Pep621Config(BaseModel):
227 """PEP 621 `[project]` input model, as written by uv, hatchling, flit, PDM, setuptools and Poetry 2.x.
229 Required fields are name, and version and description unless listed as dynamic.
230 """
232 model_config = {"use_enum_values": True}
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 ]
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
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
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
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")
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