Coverage for src/somesy/rust/writer.py: 78%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-04 11:35 +0000

1"""Pyproject writers for setuptools and rust.""" 

2 

3import logging 

4from pathlib import Path 

5from typing import Any 

6 

7from rich.pretty import pretty_repr 

8from tomlkit import array, dump, inline_table, items, load, string, table 

9 

10from somesy.core.models import Entity, Person, ProjectMetadata 

11from somesy.core.writer import FieldKeyMapping, IgnoreKey, ProjectMetadataWriter 

12 

13from .models import RustConfig, check_keyword 

14 

15logger = logging.getLogger("somesy") 

16 

17 

18class Rust(ProjectMetadataWriter): 

19 """Rust config file handler parsed from Cargo.toml.""" 

20 

21 def __init__( 

22 self, 

23 path: Path, 

24 pass_validation: bool | None = False, 

25 ): 

26 """Rust config file handler parsed from Cargo.toml. 

27 

28 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

29 """ 

30 self._section = ["package"] 

31 mappings: FieldKeyMapping = { 

32 "maintainers": IgnoreKey(), 

33 } 

34 super().__init__( 

35 path, 

36 create_if_not_exists=False, 

37 direct_mappings=mappings, 

38 pass_validation=pass_validation, 

39 ) 

40 

41 def _load(self) -> None: 

42 """Load Cargo.toml file.""" 

43 with open(self.path) as f: 

44 self._data = load(f) 

45 

46 def _validate(self) -> None: 

47 """Validate rust config using pydantic class. 

48 

49 In order to preserve toml comments and structure, tomlkit library is used. 

50 Pydantic class only used for validation. 

51 """ 

52 if self.pass_validation: 

53 return 

54 config = dict(self._get_property([])) 

55 logger.debug( 

56 f"Validating config using {RustConfig.__name__}: {pretty_repr(config)}" 

57 ) 

58 RustConfig(**config) 

59 

60 def save(self, path: Path | None = None) -> None: 

61 """Save the Cargo.toml file.""" 

62 path = path or self.path 

63 

64 if ( 

65 "description" in self._data["package"] 

66 and "\n" in self._data["package"]["description"] 

67 ): 

68 self._data["package"]["description"] = string( 

69 self._data["package"]["description"], multiline=True 

70 ) 

71 

72 with open(path, "w") as f: 

73 dump(self._data, f) 

74 

75 def _get_property( 

76 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs 

77 ) -> Any: 

78 """Get a property from the Cargo.toml file.""" 

79 if isinstance(key, IgnoreKey): 

80 return None 

81 key_path = [key] if isinstance(key, str) else key 

82 full_path = self._section + key_path 

83 return super()._get_property(full_path, remove=remove, **kwargs) 

84 

85 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None: 

86 """Set a property in the Cargo.toml file.""" 

87 if isinstance(key, IgnoreKey): 

88 return 

89 key_path = [key] if isinstance(key, str) else key 

90 

91 if not value: # remove value and clean up the sub-dict 

92 self._get_property(key_path, remove=True) 

93 return 

94 

95 # get the tomlkit object of the section 

96 dat = self._get_property([]) 

97 

98 # dig down, create missing nested objects on the fly 

99 curr = dat 

100 for path_key in key_path[:-1]: 

101 if path_key not in curr: 

102 curr.add(path_key, table()) 

103 curr = curr[path_key] 

104 

105 # Handle arrays with proper formatting 

106 if isinstance(value, list): 

107 arr = array() 

108 arr.extend(value) 

109 arr.multiline(True) 

110 # Ensure whitespace after commas in inline tables 

111 for item in arr: 

112 if isinstance(item, items.InlineTable): 

113 # Rebuild the inline table with desired formatting 

114 formatted_item = inline_table() 

115 for k, v in item.value.items(): 

116 formatted_item[k] = v 

117 formatted_item.trivia.trail = " " # Add space after each comma 

118 arr[arr.index(item)] = formatted_item 

119 curr[key_path[-1]] = arr 

120 else: 

121 curr[key_path[-1]] = value 

122 

123 @staticmethod 

124 def _from_person(person: Person | Entity): 

125 """Convert project metadata person object to rust string for person format "full name <email>.""" 

126 return person.to_name_email_string() 

127 

128 @staticmethod 

129 def _to_person(person_obj: str) -> Person | Entity | None: 

130 """Parse rust person string to a Person. It has format "full name <email>." but email is optional. 

131 

132 Since there is no way to know whether this entry is a person or an entity, we will directly convert to Person. 

133 """ 

134 try: 

135 return Person.from_name_email_string(person_obj) 

136 except (ValueError, AttributeError): 

137 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.") 

138 

139 try: 

140 return Entity.from_name_email_string(person_obj) 

141 except (ValueError, AttributeError): 

142 logger.warning(f"Cannot convert {person_obj} to Entity.") 

143 return None 

144 

145 @classmethod 

146 def _parse_people(cls, people: list[Any] | None) -> list[Person | Entity]: 

147 """Return a list of Persons parsed from list of format-specific people representations. to_person can return None, so filter out None values.""" 

148 return list(filter(None, map(cls._to_person, people or []))) 

149 

150 @property 

151 def keywords(self) -> list[str] | None: 

152 """Return the keywords of the project.""" 

153 return self._get_property(self._get_key("keywords")) 

154 

155 @keywords.setter 

156 def keywords(self, keywords: list[str]) -> None: 

157 """Set the keywords of the project.""" 

158 validated_keywords = [] 

159 for keyword in keywords: 

160 try: 

161 check_keyword(keyword) 

162 validated_keywords.append(keyword) 

163 except ValueError as e: 

164 logger.debug(f"Invalid keyword {keyword}: {e}") 

165 

166 # keyword count should max 5, so delete the rest 

167 if len(validated_keywords) > 5: 

168 validated_keywords = validated_keywords[:5] 

169 self._set_property(self._get_key("keywords"), validated_keywords) 

170 

171 def sync(self, metadata: ProjectMetadata) -> None: 

172 """Sync the rust config with the project metadata.""" 

173 super().sync(metadata) 

174 

175 if isinstance(metadata.license, list): 

176 self.license = " OR ".join(license.value for license in metadata.license) 

177 

178 # if there is a license file, remove the license field 

179 if self._get_property(self._get_key("license_file")): 

180 self.license = None