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
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-10 14:56 +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 dump, load, 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
63 with open(path, "w") as f:
64 dump(self._data, f)
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)
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
82 if not value: # remove value and clean up the sub-dict
83 self._get_property(key_path, remove=True)
84 return
86 # get the tomlkit object of the section
87 dat = self._get_property([])
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
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()
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.
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.")
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
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 [])))
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"))
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}")
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)
145 def sync(self, metadata: ProjectMetadata) -> None:
146 """Sync the rust config with the project metadata."""
147 super().sync(metadata)
149 # if there is a license file, remove the license field
150 if self._get_key("license_file"):
151 self.license = None