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

1"""Pyproject models.""" 

2 

3import re 

4from pathlib import Path 

5from typing import Annotated, Literal 

6 

7from packaging.version import parse as parse_version 

8from pydantic import BaseModel, Field, field_validator, model_validator 

9 

10from somesy.core.types import HttpUrlStr 

11 

12 

13class WorkspaceRef(BaseModel): 

14 """Value inherited from the workspace, i.e. `{workspace = true}`.""" 

15 

16 workspace: Literal[True] 

17 

18 

19class RustConfig(BaseModel): 

20 """Rust configuration model.""" 

21 

22 model_config = {"use_enum_values": True} 

23 

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 ) 

70 

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 

78 

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 

90 

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") 

101 

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 

108 

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") 

112 

113 for keyword in v or []: 

114 check_keyword(keyword) 

115 

116 return v 

117 

118 

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 ) 

126 

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") 

130 

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 +")