Coverage for src/somesy/pyproject/models.py: 79%
145 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-14 13:02 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-14 13:02 +0000
1"""Pyproject models."""
3from enum import Enum
4from logging import getLogger
5from pathlib import Path
6from typing import Dict, List, Optional, Set, Union
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)
17from typing_extensions import Annotated
19from somesy.core.models import LicenseEnum
20from somesy.core.types import HttpUrlStr
22EMailAddress = TypeAdapter(EmailStr)
23logger = getLogger("somesy")
26class STPerson(BaseModel):
27 """Person model for setuptools."""
29 name: Annotated[str, Field(min_length=1)]
30 email: Annotated[Optional[str], Field(min_length=1)] = None
32 def __str__(self):
33 """Return string representation of STPerson."""
34 if self.email:
35 return f"{self.name} <{self.email}>"
36 else:
37 return self.name
40class License(BaseModel):
41 """License model for setuptools."""
43 model_config = dict(validate_assignment=True)
45 file: Optional[Path] = None
46 text: Optional[LicenseEnum] = None
48 @model_validator(mode="before")
49 @classmethod
50 def validate_xor(cls, values):
51 """Validate that only one of file or text is set."""
52 # check if this has just str or list of str
53 if isinstance(values, str):
54 if values in LicenseEnum.__members__:
55 return {"text": values}
56 else:
57 raise ValueError("Invalid license.")
58 if isinstance(values, list):
59 # check if all elements are valid string for LicenseEnum
60 for v in values:
61 if not isinstance(v, str):
62 raise ValueError("All elements must be strings.")
63 if v not in LicenseEnum.__members__:
64 raise ValueError("Invalid license.")
65 return values
66 if sum([bool(v) for v in values.values()]) != 1:
67 raise ValueError("Either file or text must be set.")
68 return values
71class PoetryConfig(BaseModel):
72 """Poetry configuration model."""
74 model_config = dict(use_enum_values=True)
76 name: Annotated[
77 str,
78 Field(pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$", description="Package name"),
79 ]
80 version: Annotated[
81 str,
82 Field(
83 pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$",
84 description="Package version",
85 ),
86 ]
87 description: Annotated[str, Field(description="Package description")]
88 license: Annotated[
89 Optional[Union[LicenseEnum, List[LicenseEnum], License]],
90 Field(description="An SPDX license identifier."),
91 ]
93 # v1 has str, v2 has STPerson
94 authors: Annotated[List[Union[str, STPerson]], Field(description="Package authors")]
95 maintainers: Annotated[
96 Optional[List[Union[str, STPerson]]], Field(description="Package maintainers")
97 ] = None
99 readme: Annotated[
100 Optional[Union[Path, List[Path]]], Field(description="Package readme file(s)")
101 ] = None
102 homepage: Annotated[Optional[HttpUrlStr], Field(description="Package homepage")] = (
103 None
104 )
105 repository: Annotated[
106 Optional[HttpUrlStr], Field(description="Package repository")
107 ] = None
108 documentation: Annotated[
109 Optional[HttpUrlStr], Field(description="Package documentation page")
110 ] = None
111 keywords: Annotated[
112 Optional[Set[str]], Field(description="Keywords that describe the package")
113 ] = None
114 classifiers: Annotated[
115 Optional[List[str]], Field(description="pypi classifiers")
116 ] = None
117 urls: Annotated[
118 Optional[Dict[str, HttpUrlStr]], Field(description="Package URLs")
119 ] = None
121 @field_validator("version")
122 @classmethod
123 def validate_version(cls, v):
124 """Validate version using PEP 440."""
125 try:
126 _ = parse_version(v)
127 except ValueError as err:
128 raise ValueError("Invalid version") from err
129 return v
131 @field_validator("authors", "maintainers")
132 @classmethod
133 def validate_email_format(cls, v):
134 """Validate person format, omit person that is not in correct format, don't raise an error."""
135 if v is None:
136 return []
137 validated = []
138 seen = set()
139 for author in v:
140 if isinstance(author, STPerson) and author.email:
141 if not EMailAddress.validate_python(author.email):
142 logger.warning(
143 f"Invalid email format for author/maintainer {author}."
144 )
145 else:
146 author_str = str(author)
147 if author_str not in seen:
148 seen.add(author_str)
149 validated.append(author)
150 else:
151 logger.warning(f"Same person {author} is added multiple times.")
152 elif "@" in author and EMailAddress.validate_python(
153 author.split(" ")[-1][1:-1]
154 ):
155 validated.append(author)
156 else:
157 author_str = str(author)
158 if author_str not in seen:
159 seen.add(author_str)
160 validated.append(author)
161 else:
162 logger.warning(f"Same person {author} is added multiple times.")
164 return validated
166 @field_validator("readme")
167 @classmethod
168 def validate_readme(cls, v):
169 """Validate readme file(s) by checking whether files exist."""
170 if isinstance(v, list):
171 if any(not e.is_file() for e in v):
172 logger.warning("Some readme file(s) do not exist")
173 else:
174 if not v.is_file():
175 logger.warning("Readme file does not exist")
178class ContentTypeEnum(Enum):
179 """Content type enum for setuptools field file."""
181 plain = "text/plain"
182 rst = "text/x-rst"
183 markdown = "text/markdown"
186class File(BaseModel):
187 """File model for setuptools."""
189 file: Path
190 content_type: Optional[ContentTypeEnum] = Field(alias="content-type")
193class URLs(BaseModel):
194 """URLs model for setuptools."""
196 homepage: Optional[HttpUrlStr] = None
197 repository: Optional[HttpUrlStr] = None
198 documentation: Optional[HttpUrlStr] = None
199 changelog: Optional[HttpUrlStr] = None
202class SetuptoolsConfig(BaseModel):
203 """Setuptools input model. Required fields are name, version, description, and requires_python."""
205 model_config = dict(use_enum_values=True)
207 name: Annotated[str, Field(pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$")]
208 version: Annotated[
209 str, Field(pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$")
210 ]
211 description: str
212 readme: Optional[Union[Path, List[Path], File]] = None
213 license: Optional[License] = Field(None, description="An SPDX license identifier.")
214 authors: Optional[List[STPerson]] = None
215 maintainers: Optional[List[STPerson]] = None
216 keywords: Optional[Set[str]] = None
217 classifiers: Optional[List[str]] = None
218 urls: Optional[URLs] = None
220 @field_validator("version")
221 @classmethod
222 def validate_version(cls, v):
223 """Validate version using PEP 440."""
224 try:
225 _ = parse_version(v)
226 except ValueError as err:
227 raise ValueError("Invalid version") from err
228 return v
230 @field_validator("readme")
231 @classmethod
232 def validate_readme(cls, v):
233 """Validate readme file(s) by checking whether files exist."""
234 if isinstance(v, list):
235 if any(not e.is_file() for e in v):
236 raise ValueError("Some file(s) do not exist")
237 elif type(v) is File:
238 if not Path(v.file).is_file():
239 raise ValueError("File does not exist")
240 else:
241 if not v.is_file():
242 raise ValueError("File does not exist")
244 @field_validator("authors", "maintainers")
245 @classmethod
246 def validate_email_format(cls, v):
247 """Validate email format."""
248 for person in v:
249 if person.email:
250 if not EMailAddress.validate_python(person.email):
251 raise ValueError("Invalid email format")
252 return v