Coverage for src/somesy/pyproject/writer.py: 89%
199 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 11:27 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 11:27 +0000
1"""Pyproject writers for setuptools and poetry."""
3import logging
4from pathlib import Path
5from typing import Any
7import tomlkit
8import wrapt
9from rich.pretty import pretty_repr
10from tomlkit import load
11from tomlkit.items import InlineTable
13from somesy.core.log import VERBOSE
14from somesy.core.models import Entity, Person, ProjectMetadata
15from somesy.core.writer import IgnoreKey, ProjectMetadataWriter
17from .models import License, PoetryConfig, SetuptoolsConfig
19logger = logging.getLogger("somesy")
22def license_expression(licenses) -> str:
23 """Convert one or more license identifiers to an SPDX expression."""
24 return " OR ".join(
25 str(license)
26 for license in (licenses if isinstance(licenses, list) else [licenses])
27 )
30class PyprojectCommon(ProjectMetadataWriter):
31 """Poetry config file handler parsed from pyproject.toml."""
33 def __init__(
34 self,
35 path: Path,
36 *,
37 section: list[str],
38 model_cls,
39 direct_mappings=None,
40 pass_validation: bool | None = False,
41 ):
42 """Poetry config file handler parsed from pyproject.toml.
44 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
45 """
46 self._model_cls = model_cls
47 self._section = section
48 super().__init__(
49 path,
50 create_if_not_exists=False,
51 direct_mappings=direct_mappings or {},
52 pass_validation=pass_validation,
53 )
55 @property
56 def _dynamic_fields(self) -> list[str]:
57 """Return the list of fields marked as dynamic in pyproject.toml."""
58 return self._get_property(["dynamic"]) or []
60 @property
61 def version(self) -> str | None:
62 """Return the version of the project."""
63 return self._get_property(self._get_key("version"))
65 @version.setter
66 def version(self, version: str | None) -> None:
67 """Set version, skipping if listed as dynamic."""
68 if "version" in self._dynamic_fields:
69 if version:
70 logger.warning(
71 "Field 'version' is listed as dynamic — skipping sync from somesy."
72 )
73 return
74 self._set_property(self._get_key("version"), version)
76 @property
77 def description(self) -> str | None:
78 """Return the description of the project."""
79 return self._get_property(self._get_key("description"))
81 @description.setter
82 def description(self, description: str) -> None:
83 """Set description, skipping if listed as dynamic."""
84 if "description" in self._dynamic_fields:
85 if description:
86 logger.warning(
87 "Field 'description' is listed as dynamic — skipping sync from somesy."
88 )
89 return
90 self._set_property(self._get_key("description"), description)
92 def _load(self) -> None:
93 """Load pyproject.toml file."""
94 with open(self.path) as f:
95 self._data = tomlkit.load(f)
97 def _validate(self) -> None:
98 """Validate poetry config using pydantic class.
100 In order to preserve toml comments and structure, tomlkit library is used.
101 Pydantic class only used for validation.
102 """
103 if self.pass_validation:
104 return
105 config = dict(self._get_property([]))
106 logger.debug(
107 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
108 )
109 self._model_cls(**config)
111 def save(self, path: Path | None = None) -> None:
112 """Save the pyproject file."""
113 path = path or self.path
115 with open(path, "w") as f:
116 tomlkit.dump(self._data, f)
118 def _get_property(
119 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs
120 ) -> Any:
121 """Get a property from the pyproject.toml file."""
122 if isinstance(key, IgnoreKey):
123 return None
124 key_path = [key] if isinstance(key, str) else key
125 full_path = self._section + key_path
126 return super()._get_property(full_path, remove=remove, **kwargs)
128 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None:
129 """Set a property in the pyproject.toml file."""
130 if isinstance(key, IgnoreKey):
131 return
132 key_path = [key] if isinstance(key, str) else key
134 if not value: # remove value and clean up the sub-dict
135 self._get_property(key_path, remove=True)
136 return
138 # get the tomlkit object of the section
139 dat = self._get_property([])
141 # dig down, create missing nested objects on the fly
142 curr = dat
143 for path_key in key_path[:-1]:
144 if path_key not in curr:
145 curr.add(path_key, tomlkit.table())
146 curr = curr[path_key]
148 # Handle arrays with proper formatting
149 if isinstance(value, list):
150 array = tomlkit.array()
151 array.extend(value)
152 array.multiline(True)
153 # Ensure whitespace after commas in inline tables
154 for item in array:
155 if isinstance(item, InlineTable):
156 # Rebuild the inline table with desired formatting
157 formatted_item = tomlkit.inline_table()
158 for k, v in item.value.items():
159 formatted_item[k] = v
160 formatted_item.trivia.trail = " " # Add space after each comma
161 array[array.index(item)] = formatted_item
162 curr[key_path[-1]] = array
163 else:
164 curr[key_path[-1]] = value
167class Poetry(PyprojectCommon):
168 """Poetry config file handler parsed from pyproject.toml."""
170 def __init__(
171 self,
172 path: Path,
173 pass_validation: bool | None = False,
174 version: int | None = 1,
175 ):
176 """Poetry config file handler parsed from pyproject.toml.
178 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
179 """
180 self._poetry_version = version or 1
181 v2_mappings = {
182 "homepage": ["urls", "homepage"],
183 "repository": ["urls", "repository"],
184 "documentation": ["urls", "documentation"],
185 "license": ["license", "text"],
186 }
187 if version == 1:
188 super().__init__(
189 path,
190 section=["tool", "poetry"],
191 model_cls=PoetryConfig,
192 pass_validation=pass_validation,
193 )
194 else:
195 super().__init__(
196 path,
197 section=["project"],
198 model_cls=PoetryConfig,
199 pass_validation=pass_validation,
200 direct_mappings=v2_mappings,
201 )
203 @staticmethod
204 def _from_person(person: Person | Entity, poetry_version: int = 1):
205 """Convert project metadata person object to poetry string for person format "full name <email>."""
206 if poetry_version == 1:
207 return person.to_name_email_string()
208 else:
209 response = {"name": person.full_name}
210 if person.email:
211 response["email"] = person.email
212 return response
214 @staticmethod
215 def _to_person(
216 person_obj: str | dict[str, str],
217 ) -> Person | Entity | None:
218 """Convert from free string to person or entity object."""
219 if isinstance(person_obj, dict):
220 temp = str(person_obj["name"])
221 if "email" in person_obj:
222 temp = f"{temp} <{person_obj['email']}>"
223 person_obj = temp
224 try:
225 return Person.from_name_email_string(person_obj)
226 except (ValueError, AttributeError):
227 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
229 try:
230 return Entity.from_name_email_string(person_obj)
231 except (ValueError, AttributeError):
232 logger.warning(f"Cannot convert {person_obj} to Entity.")
233 return None
235 @property
236 def license(self) -> License | str | None:
237 """Get license from pyproject.toml file."""
238 raw_license = self._get_property(["license"])
239 if self._poetry_version == 1:
240 return raw_license
241 if raw_license is None:
242 return None
243 if isinstance(raw_license, str):
244 return raw_license
245 return raw_license
247 @license.setter
248 def license(self, license: License | str) -> None:
249 """Set license in pyproject.toml file."""
250 # if version is 1, set license as str
251 if self._poetry_version == 1:
252 self._set_property(["license"], license)
253 else:
254 self._set_property(["license"], license)
256 def sync(self, metadata: ProjectMetadata) -> None:
257 """Sync metadata with pyproject.toml file."""
258 # Store original _from_person method
259 original_from_person = self._from_person
261 # Override _from_person to include poetry_version
262 self._from_person = lambda person: original_from_person( # type: ignore
263 person, poetry_version=self._poetry_version
264 )
266 # Call parent sync method
267 super().sync(metadata)
269 # Restore original _from_person method
270 self._from_person = original_from_person # type: ignore
272 if metadata.license:
273 self.license = license_expression(metadata.license)
275 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
276 if self._poetry_version == 2:
277 if (
278 "description" in self._data["project"]
279 and "\n" in self._data["project"]["description"]
280 ):
281 self._data["project"]["description"] = tomlkit.string(
282 self._data["project"]["description"], multiline=True
283 )
284 # Move urls section to the end if it exists
285 if "urls" in self._data["project"]:
286 urls = self._data["project"].pop("urls")
287 self._data["project"]["urls"] = urls
290class SetupTools(PyprojectCommon):
291 """Setuptools config file handler parsed from setup.cfg."""
293 def __init__(self, path: Path, pass_validation: bool | None = False):
294 """Setuptools config file handler parsed from pyproject.toml.
296 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
297 """
298 section = ["project"]
299 mappings = {
300 "homepage": ["urls", "homepage"],
301 "repository": ["urls", "repository"],
302 "documentation": ["urls", "documentation"],
303 }
304 super().__init__(
305 path,
306 section=section,
307 direct_mappings=mappings,
308 model_cls=SetuptoolsConfig,
309 pass_validation=pass_validation,
310 )
312 @staticmethod
313 def _from_person(person: Person | Entity):
314 """Convert project metadata person object to setuptools dict for person format."""
315 response = {"name": person.full_name}
316 if person.email:
317 response["email"] = person.email
318 return response
320 @staticmethod
321 def _to_person(person_obj: str | dict) -> Entity | Person | None:
322 """Parse setuptools person string to a Person/Entity."""
323 # NOTE: for our purposes, does not matter what are given or family names,
324 # we only compare on full_name anyway.
325 if isinstance(person_obj, dict):
326 temp = str(person_obj["name"])
327 if "email" in person_obj:
328 temp = f"{temp} <{person_obj['email']}>"
329 person_obj = temp
331 try:
332 return Person.from_name_email_string(person_obj)
333 except (ValueError, AttributeError):
334 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
336 try:
337 return Entity.from_name_email_string(person_obj)
338 except (ValueError, AttributeError):
339 logger.warning(f"Cannot convert {person_obj} to Entity.")
340 return None
342 def sync(self, metadata: ProjectMetadata) -> None:
343 """Sync metadata with pyproject.toml file and fix license field."""
344 super().sync(metadata)
345 if metadata.license:
346 self.license = license_expression(metadata.license)
349# ----
352class Pyproject(wrapt.ObjectProxy):
353 """Class for syncing pyproject file with other metadata files."""
355 __wrapped__: SetupTools | Poetry
357 def __init__(self, path: Path, pass_validation: bool | None = False):
358 """Pyproject wrapper class. Wraps either setuptools or poetry.
360 Args:
361 path (Path): Path to pyproject.toml file.
362 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
364 Raises:
365 FileNotFoundError: Raised when pyproject.toml file is not found.
366 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
368 """
369 data = None
370 if not path.is_file():
371 raise FileNotFoundError(f"pyproject file {path} not found")
373 with open(path, "r") as f:
374 data = load(f)
376 # inspect file to pick suitable project metadata writer
377 is_poetry = "tool" in data and "poetry" in data["tool"]
378 has_project = "project" in data
380 if is_poetry:
381 if has_project:
382 logger.log(
383 VERBOSE,
384 "Found Poetry 2.x metadata with project section in pyproject.toml",
385 )
386 else:
387 logger.log(VERBOSE, "Found Poetry 1.x metadata in pyproject.toml")
388 self.__wrapped__ = Poetry(
389 path, pass_validation=pass_validation, version=2 if has_project else 1
390 )
391 elif has_project and not is_poetry:
392 logger.log(VERBOSE, "Found setuptools-based metadata in pyproject.toml")
393 self.__wrapped__ = SetupTools(path, pass_validation=pass_validation)
394 else:
395 msg = "The pyproject.toml file is ambiguous. For Poetry projects, ensure [tool.poetry] section exists. For setuptools, ensure [project] section exists without [tool.poetry]"
396 raise ValueError(msg)
398 super().__init__(self.__wrapped__)