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

98 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-14 13:02 +0000

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

2 

3import logging 

4from pathlib import Path 

5from typing import Any, List, Optional, Union 

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: Optional[bool] = 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: Optional[Path] = None) -> None: 

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

62 path = path or self.path 

63 

64 if "description" in self._data["package"]: 

65 if "\n" in self._data["package"]["description"]: 

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

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

68 ) 

69 

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

71 dump(self._data, f) 

72 

73 def _get_property( 

74 self, key: Union[str, List[str], IgnoreKey], *, remove: bool = False, **kwargs 

75 ) -> Optional[Any]: 

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

77 if isinstance(key, IgnoreKey): 

78 return None 

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

80 full_path = self._section + key_path 

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

82 

83 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None: 

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

85 if isinstance(key, IgnoreKey): 

86 return 

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

88 

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

90 self._get_property(key_path, remove=True) 

91 return 

92 

93 # get the tomlkit object of the section 

94 dat = self._get_property([]) 

95 

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

97 curr = dat 

98 for key in key_path[:-1]: 

99 if key not in curr: 

100 curr.add(key, table()) 

101 curr = curr[key] 

102 

103 # Handle arrays with proper formatting 

104 if isinstance(value, list): 

105 arr = array() 

106 arr.extend(value) 

107 arr.multiline(True) 

108 # Ensure whitespace after commas in inline tables 

109 for item in arr: 

110 if isinstance(item, items.InlineTable): 

111 # Rebuild the inline table with desired formatting 

112 formatted_item = inline_table() 

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

114 formatted_item[k] = v 

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

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

117 curr[key_path[-1]] = arr 

118 else: 

119 curr[key_path[-1]] = value 

120 

121 @staticmethod 

122 def _from_person(person: Union[Person, Entity]): 

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

124 return person.to_name_email_string() 

125 

126 @staticmethod 

127 def _to_person(person: str) -> Optional[Union[Person, Entity]]: 

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

129 

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

131 """ 

132 try: 

133 return Person.from_name_email_string(person) 

134 except (ValueError, AttributeError): 

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

136 

137 try: 

138 return Entity.from_name_email_string(person) 

139 except (ValueError, AttributeError): 

140 logger.warning(f"Cannot convert {person} to Entity.") 

141 return None 

142 

143 @classmethod 

144 def _parse_people(cls, people: Optional[List[Any]]) -> List[Person]: 

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

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

147 

148 @property 

149 def keywords(self) -> Optional[List[str]]: 

150 """Return the keywords of the project.""" 

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

152 

153 @keywords.setter 

154 def keywords(self, keywords: List[str]) -> None: 

155 """Set the keywords of the project.""" 

156 validated_keywords = [] 

157 for keyword in keywords: 

158 try: 

159 check_keyword(keyword) 

160 validated_keywords.append(keyword) 

161 except ValueError as e: 

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

163 

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

165 if len(validated_keywords) > 5: 

166 validated_keywords = validated_keywords[:5] 

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

168 

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

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

171 super().sync(metadata) 

172 

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

174 if self._get_key("license_file"): 

175 self.license = None