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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
1"""Pyproject writers for setuptools and rust."""
3import logging
4from pathlib import Path
5from typing import Any
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: bool | None = 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: Path | None = None) -> None:
61 """Save the Cargo.toml file."""
62 path = path or self.path
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 )
72 with open(path, "w") as f:
73 dump(self._data, f)
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)
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
91 if not value: # remove value and clean up the sub-dict
92 self._get_property(key_path, remove=True)
93 return
95 # get the tomlkit object of the section
96 dat = self._get_property([])
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]
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
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()
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.
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.")
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
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 [])))
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"))
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}")
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)
171 def sync(self, metadata: ProjectMetadata) -> None:
172 """Sync the rust config with the project metadata."""
173 super().sync(metadata)
175 if isinstance(metadata.license, list):
176 self.license = " OR ".join(license.value for license in metadata.license)
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