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
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +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])?$"
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
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
50class STPerson(BaseModel):
51 """Person model for PEP 621 `[project]` metadata."""
53 name: Annotated[str, Field(min_length=1)]
54 email: Annotated[str | None, Field(min_length=1)] = None
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
64class License(BaseModel):
65 """License model for PEP 621 `[project]` metadata."""
67 model_config = {"validate_assignment": True}
69 file: Path | None = None
70 text: LicenseEnum | None = None
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
95class PoetryConfig(BaseModel):
96 """Poetry configuration model."""
98 model_config = {"use_enum_values": True}
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 ]
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)
124 license: Annotated[
125 LicenseEnum | list[LicenseEnum] | License | str | None,
126 Field(description="An SPDX license identifier."),
127 ] = None
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
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 )
155 @field_validator("version")
156 @classmethod
157 def validate_version(cls, v):
158 """Validate version using PEP 440."""
159 return _check_version(v)
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.")
181 return validated
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
196class ContentTypeEnum(Enum):
197 """Content type enum for the PEP 621 readme file field."""
199 plain = "text/plain"
200 rst = "text/x-rst"
201 markdown = "text/markdown"
204class File(BaseModel):
205 """File model for PEP 621 `[project]` metadata."""
207 file: Path
208 content_type: ContentTypeEnum | None = Field(alias="content-type")
211class URLs(BaseModel):
212 """URLs model for PEP 621 `[project]` metadata."""
214 homepage: HttpUrlStr | None = None
215 repository: HttpUrlStr | None = None
216 documentation: HttpUrlStr | None = None
217 changelog: HttpUrlStr | None = None
220class Pep621Config(BaseModel):
221 """PEP 621 `[project]` input model, as written by uv, hatchling, flit, PDM, setuptools and Poetry 2.x.
223 Required fields are name, and version and description unless listed as dynamic.
224 """
226 model_config = {"use_enum_values": True}
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 ]
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)
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
256 @field_validator("version")
257 @classmethod
258 def validate_version(cls, v):
259 """Validate version using PEP 440."""
260 return _check_version(v)
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
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