Coverage for src/somesy/pyproject/writer.py: 86%
194 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-10 14:57 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-10 14:57 +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 if not path.is_file():
69 with open(path, "w") as f:
70 tomlkit.dump(self._data, f)
71 return
73 with open(path, "r") as f:
74 # tomlkit formatting sometimes creates empty lines, dont change if context is not changed
75 existing_data = f.read()
77 # remove empty lines
78 existing_data = existing_data.replace("\n", "")
80 new_data = tomlkit.dumps(self._data)
81 new_data = new_data.replace("\n", "")
83 if existing_data != new_data:
84 with open(path, "w") as f:
85 tomlkit.dump(self._data, f)
86 else:
87 logger.debug("No changes to pyproject.toml file")
89 def _get_property(
90 self, key: Union[str, List[str]], *, remove: bool = False, **kwargs
91 ) -> Optional[Any]:
92 """Get a property from the pyproject.toml file."""
93 key_path = [key] if isinstance(key, str) else key
94 full_path = self._section + key_path
95 return super()._get_property(full_path, remove=remove, **kwargs)
97 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None:
98 """Set a property in the pyproject.toml file."""
99 if isinstance(key, IgnoreKey):
100 return
101 key_path = [key] if isinstance(key, str) else key
103 if not value: # remove value and clean up the sub-dict
104 self._get_property(key_path, remove=True)
105 return
107 # get the tomlkit object of the section
108 dat = self._get_property([])
110 # dig down, create missing nested objects on the fly
111 curr = dat
112 for key in key_path[:-1]:
113 if key not in curr:
114 curr.add(key, tomlkit.table())
115 curr = curr[key]
117 # Handle arrays with proper formatting
118 if isinstance(value, list):
119 array = tomlkit.array()
120 array.extend(value)
121 array.multiline(True)
122 curr[key_path[-1]] = array
123 else:
124 curr[key_path[-1]] = value
127class Poetry(PyprojectCommon):
128 """Poetry config file handler parsed from pyproject.toml."""
130 def __init__(
131 self,
132 path: Path,
133 pass_validation: Optional[bool] = False,
134 version: Optional[int] = 1,
135 ):
136 """Poetry config file handler parsed from pyproject.toml.
138 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
139 """
140 self._poetry_version = version
141 v2_mappings = {
142 "homepage": ["urls", "homepage"],
143 "repository": ["urls", "repository"],
144 "documentation": ["urls", "documentation"],
145 "license": ["license", "text"],
146 }
147 if version == 1:
148 super().__init__(
149 path,
150 section=["tool", "poetry"],
151 model_cls=PoetryConfig,
152 pass_validation=pass_validation,
153 )
154 else:
155 super().__init__(
156 path,
157 section=["project"],
158 model_cls=PoetryConfig,
159 pass_validation=pass_validation,
160 direct_mappings=v2_mappings,
161 )
163 @staticmethod
164 def _from_person(person: Union[Person, Entity], poetry_version: int = 1):
165 """Convert project metadata person object to poetry string for person format "full name <email>."""
166 if poetry_version == 1:
167 return person.to_name_email_string()
168 else:
169 response = {"name": person.full_name}
170 if person.email:
171 response["email"] = person.email
172 return response
174 @staticmethod
175 def _to_person(
176 person: Union[str, Dict[str, str]],
177 ) -> Optional[Union[Person, Entity]]:
178 """Convert from free string to person or entity object."""
179 if isinstance(person, dict):
180 temp = str(person["name"])
181 if "email" in person:
182 temp = f"{temp} <{person['email']}>"
183 person = temp
184 try:
185 return Person.from_name_email_string(person)
186 except (ValueError, AttributeError):
187 logger.info(f"Cannot convert {person} to Person object, trying Entity.")
189 try:
190 return Entity.from_name_email_string(person)
191 except (ValueError, AttributeError):
192 logger.warning(f"Cannot convert {person} to Entity.")
193 return None
195 @property
196 def license(self) -> Optional[Union[License, str]]:
197 """Get license from pyproject.toml file."""
198 raw_license = self._get_property(["license"])
199 if self._poetry_version == 1:
200 return raw_license
201 if raw_license is None:
202 return None
203 if isinstance(raw_license, str):
204 return License(text=raw_license)
205 return raw_license
207 @license.setter
208 def license(self, value: Union[License, str]) -> None:
209 """Set license in pyproject.toml file."""
210 # if version is 1, set license as str
211 if self._poetry_version == 1:
212 self._set_property(["license"], value)
213 else:
214 # if version is 2 and str, set as text
215 if isinstance(value, str):
216 self._set_property(["license"], {"text": value})
217 else:
218 self._set_property(["license"], value)
220 def sync(self, metadata: ProjectMetadata) -> None:
221 """Sync metadata with pyproject.toml file."""
222 # Store original _from_person method
223 original_from_person = self._from_person
225 # Override _from_person to include poetry_version
226 self._from_person = lambda person: original_from_person( # type: ignore
227 person, poetry_version=self._poetry_version
228 )
230 # Call parent sync method
231 super().sync(metadata)
233 # Restore original _from_person method
234 self._from_person = original_from_person # type: ignore
236 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
237 if self._poetry_version == 2:
238 # if license field has text, or file, make it inline table of tomlkit
239 if self._get_property(["license"]) is not None:
240 license_value = self._get_property(["license"])
241 # Create and populate inline table
242 inline_table = tomlkit.inline_table()
243 if isinstance(license_value, str):
244 inline_table["text"] = license_value
245 elif isinstance(license_value, dict):
246 if "text" in license_value:
247 inline_table["text"] = license_value["text"]
248 elif "file" in license_value:
249 inline_table["file"] = license_value["file"]
250 elif hasattr(license_value, "text"):
251 inline_table["text"] = license_value.text
252 elif hasattr(license_value, "file"):
253 inline_table["file"] = license_value.file
255 # Create a new table with the same structure
256 table = tomlkit.table()
257 table.add("license", inline_table)
258 if "license" in self._data["project"]:
259 # Copy the whitespace/formatting from the existing table
260 table.trivia.indent = self._data["project"]["license"].trivia.indent
261 table.trivia.comment_ws = self._data["project"][
262 "license"
263 ].trivia.comment_ws
264 table.trivia.comment = self._data["project"][
265 "license"
266 ].trivia.comment
267 table.trivia.trail = self._data["project"]["license"].trivia.trail
269 self._data["project"]["license"] = table["license"]
271 # Move urls section to the end if it exists
272 if "urls" in self._data["project"]:
273 urls = self._data["project"].pop("urls")
274 self._data["project"]["urls"] = urls
277class SetupTools(PyprojectCommon):
278 """Setuptools config file handler parsed from setup.cfg."""
280 def __init__(self, path: Path, pass_validation: Optional[bool] = False):
281 """Setuptools config file handler parsed from pyproject.toml.
283 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
284 """
285 section = ["project"]
286 mappings = {
287 "homepage": ["urls", "homepage"],
288 "repository": ["urls", "repository"],
289 "documentation": ["urls", "documentation"],
290 "license": ["license", "text"],
291 }
292 super().__init__(
293 path,
294 section=section,
295 direct_mappings=mappings,
296 model_cls=SetuptoolsConfig,
297 pass_validation=pass_validation,
298 )
300 @staticmethod
301 def _from_person(person: Person):
302 """Convert project metadata person object to setuptools dict for person format."""
303 response = {"name": person.full_name}
304 if person.email:
305 response["email"] = person.email
306 return response
308 @staticmethod
309 def _to_person(person: Union[str, dict]) -> Optional[Union[Entity, Person]]:
310 """Parse setuptools person string to a Person/Entity."""
311 # NOTE: for our purposes, does not matter what are given or family names,
312 # we only compare on full_name anyway.
313 if isinstance(person, dict):
314 temp = str(person["name"])
315 if "email" in person:
316 temp = f"{temp} <{person['email']}>"
317 person = temp
319 try:
320 return Person.from_name_email_string(person)
321 except (ValueError, AttributeError):
322 logger.info(f"Cannot convert {person} to Person object, trying Entity.")
324 try:
325 return Entity.from_name_email_string(person)
326 except (ValueError, AttributeError):
327 logger.warning(f"Cannot convert {person} to Entity.")
328 return None
330 def sync(self, metadata: ProjectMetadata) -> None:
331 """Sync metadata with pyproject.toml file and fix license field."""
332 super().sync(metadata)
334 # if license field has both text and file, remove file
335 if (
336 self._get_property(["license", "file"]) is not None
337 and self._get_property(["license", "text"]) is not None
338 ):
339 # delete license file property
340 self._data["project"]["license"].pop("file")
343# ----
346class Pyproject(wrapt.ObjectProxy):
347 """Class for syncing pyproject file with other metadata files."""
349 __wrapped__: Union[SetupTools, Poetry]
351 def __init__(self, path: Path, pass_validation: Optional[bool] = False):
352 """Pyproject wrapper class. Wraps either setuptools or poetry.
354 Args:
355 path (Path): Path to pyproject.toml file.
356 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
358 Raises:
359 FileNotFoundError: Raised when pyproject.toml file is not found.
360 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
362 """
363 data = None
364 if not path.is_file():
365 raise FileNotFoundError(f"pyproject file {path} not found")
367 with open(path, "r") as f:
368 data = load(f)
370 # inspect file to pick suitable project metadata writer
371 is_poetry = "tool" in data and "poetry" in data["tool"]
372 has_project = "project" in data
374 if is_poetry:
375 if has_project:
376 logger.verbose(
377 "Found Poetry 2.x metadata with project section in pyproject.toml"
378 )
379 else:
380 logger.verbose("Found Poetry 1.x metadata in pyproject.toml")
381 self.__wrapped__ = Poetry(
382 path, pass_validation=pass_validation, version=2 if has_project else 1
383 )
384 elif has_project and not is_poetry:
385 logger.verbose("Found setuptools-based metadata in pyproject.toml")
386 self.__wrapped__ = SetupTools(path, pass_validation=pass_validation)
387 else:
388 msg = "The pyproject.toml file is ambiguous. For Poetry projects, ensure [tool.poetry] section exists. For setuptools, ensure [project] section exists without [tool.poetry]"
389 raise ValueError(msg)
391 super().__init__(self.__wrapped__)