Coverage for src/somesy/rust/models.py: 86%
65 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
1"""Pyproject models."""
3import re
4from pathlib import Path
5from typing import Annotated, Literal
7from packaging.version import parse as parse_version
8from pydantic import BaseModel, Field, field_validator, model_validator
10from somesy.core.types import HttpUrlStr
13class WorkspaceRef(BaseModel):
14 """Value inherited from the workspace, i.e. `{workspace = true}`."""
16 workspace: Literal[True]
19class RustConfig(BaseModel):
20 """Rust configuration model."""
22 model_config = {"use_enum_values": True}
24 name: Annotated[
25 str,
26 Field(
27 pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$",
28 max_length=64,
29 description="Package name",
30 ),
31 ]
32 version: Annotated[
33 Annotated[str, Field(pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$")]
34 | WorkspaceRef,
35 Field(description="Package version"),
36 ]
37 description: Annotated[str | None, Field(description="Package description")] = None
38 license: Annotated[
39 str | WorkspaceRef | None,
40 Field(
41 description="A combination SPDX license identifiers with AND, OR and so on."
42 ),
43 ] = None
44 authors: Annotated[set[str] | WorkspaceRef, Field(description="Package authors")]
45 maintainers: Annotated[
46 set[str] | None, Field(description="Package maintainers")
47 ] = None
48 readme: Annotated[
49 Path | list[Path] | None, Field(description="Package readme file(s)")
50 ] = None
51 license_file: Annotated[Path | None, Field(description="Package license file")] = (
52 None
53 )
54 homepage: Annotated[HttpUrlStr | None, Field(description="Package homepage")] = None
55 repository: Annotated[
56 HttpUrlStr | None, Field(description="Package repository")
57 ] = None
58 documentation: Annotated[
59 HttpUrlStr | None, Field(description="Package documentation page")
60 ] = None
61 keywords: Annotated[
62 set[str] | None, Field(description="Keywords that describe the package")
63 ] = None
64 classifiers: Annotated[list[str] | None, Field(description="pypi classifiers")] = (
65 None
66 )
67 urls: Annotated[dict[str, HttpUrlStr] | None, Field(description="Package URLs")] = (
68 None
69 )
71 @model_validator(mode="before")
72 @classmethod
73 def license_or_file(cls, values):
74 """License and license file are mutually exclusive."""
75 if "license" in values and "license_file" in values:
76 raise ValueError("license and license_file are mutually exclusive")
77 return values
79 @field_validator("version")
80 @classmethod
81 def validate_version(cls, v):
82 """Validate version using PEP 440."""
83 if isinstance(v, WorkspaceRef):
84 return v
85 try:
86 _ = parse_version(v)
87 except ValueError as err:
88 raise ValueError("Invalid version") from err
89 return v
91 @field_validator("readme", "license_file")
92 @classmethod
93 def validate_readme(cls, v):
94 """Validate readme file(s) by checking whether files exist."""
95 if isinstance(v, list):
96 if any(not e.is_file() for e in v):
97 raise ValueError("Some file(s) do not exist")
98 else:
99 if not v.is_file():
100 raise ValueError("File does not exist")
102 @field_validator("keywords")
103 @classmethod
104 def check_keywords_field(cls, v):
105 """Check the keywords field."""
106 if v is None:
107 return v
109 # Check if number of keywords is at most 5
110 if len(v) > 5:
111 raise ValueError("A maximum of 5 keywords is allowed")
113 for keyword in v or []:
114 check_keyword(keyword)
116 return v
119def check_keyword(keyword: str):
120 """Check if keyword is valid."""
121 # Check if keyword is ASCII and has at most 20 characters
122 if not keyword.isascii() or len(keyword) > 20:
123 raise ValueError(
124 "Each keyword must be ASCII text and have at most 20 characters"
125 )
127 # Check if keyword starts with an alphanumeric character
128 if not re.match(r"^[a-zA-Z0-9]", keyword):
129 raise ValueError("Each keyword must start with an alphanumeric character")
131 # Check if keyword contains only allowed characters
132 if not re.match(r"^[a-zA-Z0-9_\-+]+$", keyword):
133 raise ValueError("Keywords can only contain letters, numbers, _, -, or +")