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

83 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-10 14:56 +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 dump, load, 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 with open(path, "w") as f: 

64 dump(self._data, f) 

65 

66 def _get_property( 

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

68 ) -> Optional[Any]: 

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

70 if isinstance(key, IgnoreKey): 

71 return None 

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

73 full_path = self._section + key_path 

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

75 

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

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

78 if isinstance(key, IgnoreKey): 

79 return 

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

81 

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

83 self._get_property(key_path, remove=True) 

84 return 

85 

86 # get the tomlkit object of the section 

87 dat = self._get_property([]) 

88 

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

90 curr = dat 

91 for key in key_path[:-1]: 

92 if key not in curr: 

93 curr.add(key, table()) 

94 curr = curr[key] 

95 curr[key_path[-1]] = value 

96 

97 @staticmethod 

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

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

100 return person.to_name_email_string() 

101 

102 @staticmethod 

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

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

105 

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

107 """ 

108 try: 

109 return Person.from_name_email_string(person) 

110 except (ValueError, AttributeError): 

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

112 

113 try: 

114 return Entity.from_name_email_string(person) 

115 except (ValueError, AttributeError): 

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

117 return None 

118 

119 @classmethod 

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

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

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

123 

124 @property 

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

126 """Return the keywords of the project.""" 

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

128 

129 @keywords.setter 

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

131 """Set the keywords of the project.""" 

132 validated_keywords = [] 

133 for keyword in keywords: 

134 try: 

135 check_keyword(keyword) 

136 validated_keywords.append(keyword) 

137 except ValueError as e: 

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

139 

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

141 if len(validated_keywords) > 5: 

142 validated_keywords = validated_keywords[:5] 

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

144 

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

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

147 super().sync(metadata) 

148 

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

150 if self._get_key("license_file"): 

151 self.license = None