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
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-14 13:02 +0000
1"""Pyproject writers for setuptools and rust."""
3import logging
4from pathlib import Path
5from typing import Any, List, Optional, Union
7from rich.pretty import pretty_repr
8from tomlkit import array, dump, inline_table, items, load, string, table
10from somesy.core.models import Entity, Person, ProjectMetadata
11from somesy.core.writer import FieldKeyMapping, IgnoreKey, ProjectMetadataWriter
13from .models import RustConfig, check_keyword
15logger = logging.getLogger("somesy")
18class Rust(ProjectMetadataWriter):
19 """Rust config file handler parsed from Cargo.toml."""
21 def __init__(
22 self,
23 path: Path,
24 pass_validation: Optional[bool] = False,
25 ):
26 """Rust config file handler parsed from Cargo.toml.
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 )
41 def _load(self) -> None:
42 """Load Cargo.toml file."""
43 with open(self.path) as f:
44 self._data = load(f)
46 def _validate(self) -> None:
47 """Validate rust config using pydantic class.
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)
60 def save(self, path: Optional[Path] = None) -> None:
61 """Save the Cargo.toml file."""
62 path = path or self.path
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 )
70 with open(path, "w") as f:
71 dump(self._data, f)
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)
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
89 if not value: # remove value and clean up the sub-dict
90 self._get_property(key_path, remove=True)
91 return
93 # get the tomlkit object of the section
94 dat = self._get_property([])
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]
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
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()
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.
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.")
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
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 [])))
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"))
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}")
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)
169 def sync(self, metadata: ProjectMetadata) -> None:
170 """Sync the rust config with the project metadata."""
171 super().sync(metadata)
173 # if there is a license file, remove the license field
174 if self._get_key("license_file"):
175 self.license = None