Coverage for src/somesy/pyproject/writer.py: 88%
171 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 poetry."""
3import logging
4from pathlib import Path
5from typing import Any, Dict, List, Optional, Union
7import tomlkit
8import wrapt
9from rich.pretty import pretty_repr
10from tomlkit import load
12from somesy.core.models import Entity, Person, ProjectMetadata
13from somesy.core.writer import IgnoreKey, ProjectMetadataWriter
15from .models import License, PoetryConfig, SetuptoolsConfig
17logger = logging.getLogger("somesy")
20class PyprojectCommon(ProjectMetadataWriter):
21 """Poetry config file handler parsed from pyproject.toml."""
23 def __init__(
24 self,
25 path: Path,
26 *,
27 section: List[str],
28 model_cls,
29 direct_mappings=None,
30 pass_validation: Optional[bool] = False,
31 ):
32 """Poetry config file handler parsed from pyproject.toml.
34 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
35 """
36 self._model_cls = model_cls
37 self._section = section
38 super().__init__(
39 path,
40 create_if_not_exists=False,
41 direct_mappings=direct_mappings or {},
42 pass_validation=pass_validation,
43 )
45 def _load(self) -> None:
46 """Load pyproject.toml file."""
47 with open(self.path) as f:
48 self._data = tomlkit.load(f)
50 def _validate(self) -> None:
51 """Validate poetry config using pydantic class.
53 In order to preserve toml comments and structure, tomlkit library is used.
54 Pydantic class only used for validation.
55 """
56 if self.pass_validation:
57 return
58 config = dict(self._get_property([]))
59 logger.debug(
60 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
61 )
62 self._model_cls(**config)
64 def save(self, path: Optional[Path] = None) -> None:
65 """Save the pyproject file."""
66 path = path or self.path
68 with open(path, "w") as f:
69 tomlkit.dump(self._data, f)
71 def _get_property(
72 self, key: Union[str, List[str]], *, remove: bool = False, **kwargs
73 ) -> Optional[Any]:
74 """Get a property from the pyproject.toml file."""
75 key_path = [key] if isinstance(key, str) else key
76 full_path = self._section + key_path
77 return super()._get_property(full_path, remove=remove, **kwargs)
79 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None:
80 """Set a property in the pyproject.toml file."""
81 if isinstance(key, IgnoreKey):
82 return
83 key_path = [key] if isinstance(key, str) else key
85 if not value: # remove value and clean up the sub-dict
86 self._get_property(key_path, remove=True)
87 return
89 # get the tomlkit object of the section
90 dat = self._get_property([])
92 # dig down, create missing nested objects on the fly
93 curr = dat
94 for key in key_path[:-1]:
95 if key not in curr:
96 curr.add(key, tomlkit.table())
97 curr = curr[key]
99 # Handle arrays with proper formatting
100 if isinstance(value, list):
101 array = tomlkit.array()
102 array.extend(value)
103 array.multiline(True)
104 # Ensure whitespace after commas in inline tables
105 for item in array:
106 if isinstance(item, tomlkit.items.InlineTable):
107 # Rebuild the inline table with desired formatting
108 formatted_item = tomlkit.inline_table()
109 for k, v in item.value.items():
110 formatted_item[k] = v
111 formatted_item.trivia.trail = " " # Add space after each comma
112 array[array.index(item)] = formatted_item
113 curr[key_path[-1]] = array
114 else:
115 curr[key_path[-1]] = value
118class Poetry(PyprojectCommon):
119 """Poetry config file handler parsed from pyproject.toml."""
121 def __init__(
122 self,
123 path: Path,
124 pass_validation: Optional[bool] = False,
125 version: Optional[int] = 1,
126 ):
127 """Poetry config file handler parsed from pyproject.toml.
129 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
130 """
131 self._poetry_version = version
132 v2_mappings = {
133 "homepage": ["urls", "homepage"],
134 "repository": ["urls", "repository"],
135 "documentation": ["urls", "documentation"],
136 "license": ["license", "text"],
137 }
138 if version == 1:
139 super().__init__(
140 path,
141 section=["tool", "poetry"],
142 model_cls=PoetryConfig,
143 pass_validation=pass_validation,
144 )
145 else:
146 super().__init__(
147 path,
148 section=["project"],
149 model_cls=PoetryConfig,
150 pass_validation=pass_validation,
151 direct_mappings=v2_mappings,
152 )
154 @staticmethod
155 def _from_person(person: Union[Person, Entity], poetry_version: int = 1):
156 """Convert project metadata person object to poetry string for person format "full name <email>."""
157 if poetry_version == 1:
158 return person.to_name_email_string()
159 else:
160 response = {"name": person.full_name}
161 if person.email:
162 response["email"] = person.email
163 return response
165 @staticmethod
166 def _to_person(
167 person: Union[str, Dict[str, str]],
168 ) -> Optional[Union[Person, Entity]]:
169 """Convert from free string to person or entity object."""
170 if isinstance(person, dict):
171 temp = str(person["name"])
172 if "email" in person:
173 temp = f"{temp} <{person['email']}>"
174 person = temp
175 try:
176 return Person.from_name_email_string(person)
177 except (ValueError, AttributeError):
178 logger.info(f"Cannot convert {person} to Person object, trying Entity.")
180 try:
181 return Entity.from_name_email_string(person)
182 except (ValueError, AttributeError):
183 logger.warning(f"Cannot convert {person} to Entity.")
184 return None
186 @property
187 def license(self) -> Optional[Union[License, str]]:
188 """Get license from pyproject.toml file."""
189 raw_license = self._get_property(["license"])
190 if self._poetry_version == 1:
191 return raw_license
192 if raw_license is None:
193 return None
194 if isinstance(raw_license, str):
195 return License(text=raw_license)
196 return raw_license
198 @license.setter
199 def license(self, value: Union[License, str]) -> None:
200 """Set license in pyproject.toml file."""
201 # if version is 1, set license as str
202 if self._poetry_version == 1:
203 self._set_property(["license"], value)
204 else:
205 # if version is 2 and str, set as text
206 if isinstance(value, str):
207 self._set_property(["license"], {"text": value})
208 else:
209 self._set_property(["license"], value)
211 def sync(self, metadata: ProjectMetadata) -> None:
212 """Sync metadata with pyproject.toml file."""
213 # Store original _from_person method
214 original_from_person = self._from_person
216 # Override _from_person to include poetry_version
217 self._from_person = lambda person: original_from_person( # type: ignore
218 person, poetry_version=self._poetry_version
219 )
221 # Call parent sync method
222 super().sync(metadata)
224 # Restore original _from_person method
225 self._from_person = original_from_person # type: ignore
227 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
228 if self._poetry_version == 2:
229 if "description" in self._data["project"]:
230 if "\n" in self._data["project"]["description"]:
231 self._data["project"]["description"] = tomlkit.string(
232 self._data["project"]["description"], multiline=True
233 )
234 # Move urls section to the end if it exists
235 if "urls" in self._data["project"]:
236 urls = self._data["project"].pop("urls")
237 self._data["project"]["urls"] = urls
240class SetupTools(PyprojectCommon):
241 """Setuptools config file handler parsed from setup.cfg."""
243 def __init__(self, path: Path, pass_validation: Optional[bool] = False):
244 """Setuptools config file handler parsed from pyproject.toml.
246 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
247 """
248 section = ["project"]
249 mappings = {
250 "homepage": ["urls", "homepage"],
251 "repository": ["urls", "repository"],
252 "documentation": ["urls", "documentation"],
253 "license": ["license", "text"],
254 }
255 super().__init__(
256 path,
257 section=section,
258 direct_mappings=mappings,
259 model_cls=SetuptoolsConfig,
260 pass_validation=pass_validation,
261 )
263 @staticmethod
264 def _from_person(person: Person):
265 """Convert project metadata person object to setuptools dict for person format."""
266 response = {"name": person.full_name}
267 if person.email:
268 response["email"] = person.email
269 return response
271 @staticmethod
272 def _to_person(person: Union[str, dict]) -> Optional[Union[Entity, Person]]:
273 """Parse setuptools person string to a Person/Entity."""
274 # NOTE: for our purposes, does not matter what are given or family names,
275 # we only compare on full_name anyway.
276 if isinstance(person, dict):
277 temp = str(person["name"])
278 if "email" in person:
279 temp = f"{temp} <{person['email']}>"
280 person = temp
282 try:
283 return Person.from_name_email_string(person)
284 except (ValueError, AttributeError):
285 logger.info(f"Cannot convert {person} to Person object, trying Entity.")
287 try:
288 return Entity.from_name_email_string(person)
289 except (ValueError, AttributeError):
290 logger.warning(f"Cannot convert {person} to Entity.")
291 return None
293 def sync(self, metadata: ProjectMetadata) -> None:
294 """Sync metadata with pyproject.toml file and fix license field."""
295 super().sync(metadata)
297 # if license field has both text and file, remove file
298 if (
299 self._get_property(["license", "file"]) is not None
300 and self._get_property(["license", "text"]) is not None
301 ):
302 # delete license file property
303 self._data["project"]["license"].pop("file")
306# ----
309class Pyproject(wrapt.ObjectProxy):
310 """Class for syncing pyproject file with other metadata files."""
312 __wrapped__: Union[SetupTools, Poetry]
314 def __init__(self, path: Path, pass_validation: Optional[bool] = False):
315 """Pyproject wrapper class. Wraps either setuptools or poetry.
317 Args:
318 path (Path): Path to pyproject.toml file.
319 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
321 Raises:
322 FileNotFoundError: Raised when pyproject.toml file is not found.
323 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
325 """
326 data = None
327 if not path.is_file():
328 raise FileNotFoundError(f"pyproject file {path} not found")
330 with open(path, "r") as f:
331 data = load(f)
333 # inspect file to pick suitable project metadata writer
334 is_poetry = "tool" in data and "poetry" in data["tool"]
335 has_project = "project" in data
337 if is_poetry:
338 if has_project:
339 logger.verbose(
340 "Found Poetry 2.x metadata with project section in pyproject.toml"
341 )
342 else:
343 logger.verbose("Found Poetry 1.x metadata in pyproject.toml")
344 self.__wrapped__ = Poetry(
345 path, pass_validation=pass_validation, version=2 if has_project else 1
346 )
347 elif has_project and not is_poetry:
348 logger.verbose("Found setuptools-based metadata in pyproject.toml")
349 self.__wrapped__ = SetupTools(path, pass_validation=pass_validation)
350 else:
351 msg = "The pyproject.toml file is ambiguous. For Poetry projects, ensure [tool.poetry] section exists. For setuptools, ensure [project] section exists without [tool.poetry]"
352 raise ValueError(msg)
354 super().__init__(self.__wrapped__)