Coverage for src/somesy/pyproject/writer.py: 91%
216 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
1"""Pyproject writers for PEP 621 `[project]` metadata and Poetry."""
3import logging
4import re
5from pathlib import Path
6from typing import Any
8import tomlkit
9import wrapt
10from rich.pretty import pretty_repr
11from tomlkit import load
12from tomlkit.items import InlineTable
14from somesy.core.log import VERBOSE
15from somesy.core.models import Entity, Person, ProjectMetadata
16from somesy.core.writer import IgnoreKey, ProjectMetadataWriter
18from .models import Pep621Config, PoetryConfig
20logger = logging.getLogger("somesy")
23def normalize_url_key(name: str) -> str:
24 """Return a `[project.urls]` key in a form that can be compared.
26 PEP 621 does not standardize these key names, so the same URL appears as
27 "Bug Tracker", "bug-tracker" or "bugtracker" depending on the template the
28 project started from.
29 """
30 return re.sub(r"[\s_-]+", "", name).lower()
33def license_expression(licenses) -> str:
34 """Convert one or more license identifiers to an SPDX expression."""
35 return " OR ".join(
36 str(license)
37 for license in (licenses if isinstance(licenses, list) else [licenses])
38 )
41class PyprojectCommon(ProjectMetadataWriter):
42 """Poetry config file handler parsed from pyproject.toml."""
44 def __init__(
45 self,
46 path: Path,
47 *,
48 section: list[str],
49 model_cls,
50 direct_mappings=None,
51 pass_validation: bool | None = False,
52 ):
53 """Poetry config file handler parsed from pyproject.toml.
55 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
56 """
57 self._model_cls = model_cls
58 self._section = section
59 super().__init__(
60 path,
61 create_if_not_exists=False,
62 direct_mappings=direct_mappings or {},
63 pass_validation=pass_validation,
64 )
65 self._adopt_url_key_spelling()
67 def _adopt_url_key_spelling(self) -> None:
68 """Point the url mappings at the key spellings used in the file.
70 The common project templates capitalize the `[project.urls]` keys
71 ("Homepage", "Bug Tracker"). Without this, somesy would neither read
72 those entries nor update them, and would instead write a second,
73 differently spelled entry next to them. A key somesy adds follows the
74 capitalization of the keys already there, to keep the table uniform.
75 """
76 keys = list(self._get_property(["urls"]) or {})
77 existing = {normalize_url_key(key): key for key in keys}
78 capitalized = bool(keys) and all(key[:1].isupper() for key in keys)
79 for field, key_path in self.direct_mappings.items():
80 if not isinstance(key_path, list) or key_path[:1] != ["urls"]:
81 continue
82 name = key_path[-1]
83 spelling = existing.get(normalize_url_key(name))
84 if spelling is None and capitalized:
85 spelling = name.capitalize()
86 if spelling is not None:
87 self.direct_mappings[field] = ["urls", spelling]
89 @property
90 def _dynamic_fields(self) -> list[str]:
91 """Return the list of fields marked as dynamic in pyproject.toml."""
92 return self._get_property(["dynamic"]) or []
94 @property
95 def version(self) -> str | None:
96 """Return the version of the project."""
97 return self._get_property(self._get_key("version"))
99 @version.setter
100 def version(self, version: str | None) -> None:
101 """Set version, skipping if listed as dynamic."""
102 if "version" in self._dynamic_fields:
103 if version:
104 logger.warning(
105 "Field 'version' is listed as dynamic — skipping sync from somesy."
106 )
107 return
108 self._set_property(self._get_key("version"), version)
110 @property
111 def license(self) -> str | None:
112 """Return the license of the project as an SPDX expression.
114 PEP 639 replaced the `license = { text = ... }` table with a plain
115 expression, but files predating it are still valid and must be read.
116 A `{ file = ... }` table names a license file instead of an
117 identifier, so there is no expression to report.
118 """
119 license = self._get_property(["license"])
120 if isinstance(license, dict):
121 return license.get("text")
122 return license
124 @license.setter
125 def license(self, license: str | None) -> None:
126 """Set the license of the project."""
127 self._set_property(["license"], license)
129 @property
130 def description(self) -> str | None:
131 """Return the description of the project."""
132 return self._get_property(self._get_key("description"))
134 @description.setter
135 def description(self, description: str) -> None:
136 """Set description, skipping if listed as dynamic."""
137 if "description" in self._dynamic_fields:
138 if description:
139 logger.warning(
140 "Field 'description' is listed as dynamic — skipping sync from somesy."
141 )
142 return
143 self._set_property(self._get_key("description"), description)
145 def _load(self) -> None:
146 """Load pyproject.toml file."""
147 with open(self.path) as f:
148 self._data = tomlkit.load(f)
150 def _validate(self) -> None:
151 """Validate poetry config using pydantic class.
153 In order to preserve toml comments and structure, tomlkit library is used.
154 Pydantic class only used for validation.
155 """
156 if self.pass_validation:
157 return
158 config = dict(self._get_property([]))
159 logger.debug(
160 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
161 )
162 self._model_cls(**config)
164 def save(self, path: Path | None = None) -> None:
165 """Save the pyproject file."""
166 path = path or self.path
168 with open(path, "w") as f:
169 tomlkit.dump(self._data, f)
171 def _get_property(
172 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs
173 ) -> Any:
174 """Get a property from the pyproject.toml file."""
175 if isinstance(key, IgnoreKey):
176 return None
177 key_path = [key] if isinstance(key, str) else key
178 full_path = self._section + key_path
179 return super()._get_property(full_path, remove=remove, **kwargs)
181 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None:
182 """Set a property in the pyproject.toml file."""
183 if isinstance(key, IgnoreKey):
184 return
185 key_path = [key] if isinstance(key, str) else key
187 if not value: # remove value and clean up the sub-dict
188 self._get_property(key_path, remove=True)
189 return
191 # get the tomlkit object of the section
192 dat = self._get_property([])
194 # dig down, create missing nested objects on the fly
195 curr = dat
196 for path_key in key_path[:-1]:
197 if path_key not in curr:
198 curr.add(path_key, tomlkit.table())
199 curr = curr[path_key]
201 # Handle arrays with proper formatting
202 if isinstance(value, list):
203 array = tomlkit.array()
204 array.extend(value)
205 array.multiline(True)
206 # Ensure whitespace after commas in inline tables
207 for item in array:
208 if isinstance(item, InlineTable):
209 # Rebuild the inline table with desired formatting
210 formatted_item = tomlkit.inline_table()
211 for k, v in item.value.items():
212 formatted_item[k] = v
213 formatted_item.trivia.trail = " " # Add space after each comma
214 array[array.index(item)] = formatted_item
215 curr[key_path[-1]] = array
216 else:
217 curr[key_path[-1]] = value
220class Poetry(PyprojectCommon):
221 """Poetry config file handler parsed from pyproject.toml."""
223 def __init__(
224 self,
225 path: Path,
226 pass_validation: bool | None = False,
227 version: int | None = 1,
228 ):
229 """Poetry config file handler parsed from pyproject.toml.
231 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
232 """
233 self._poetry_version = version or 1
234 v2_mappings = {
235 "homepage": ["urls", "homepage"],
236 "repository": ["urls", "repository"],
237 "documentation": ["urls", "documentation"],
238 }
239 if version == 1:
240 super().__init__(
241 path,
242 section=["tool", "poetry"],
243 model_cls=PoetryConfig,
244 pass_validation=pass_validation,
245 )
246 else:
247 super().__init__(
248 path,
249 section=["project"],
250 model_cls=PoetryConfig,
251 pass_validation=pass_validation,
252 direct_mappings=v2_mappings,
253 )
255 @staticmethod
256 def _from_person(person: Person | Entity, poetry_version: int = 1):
257 """Convert project metadata person object to poetry string for person format "full name <email>."""
258 if poetry_version == 1:
259 return person.to_name_email_string()
260 else:
261 response = {"name": person.full_name}
262 if person.email:
263 response["email"] = person.email
264 return response
266 @staticmethod
267 def _to_person(
268 person_obj: str | dict[str, str],
269 ) -> Person | Entity | None:
270 """Convert from free string to person or entity object."""
271 if isinstance(person_obj, dict):
272 temp = str(person_obj["name"])
273 if "email" in person_obj:
274 temp = f"{temp} <{person_obj['email']}>"
275 person_obj = temp
276 try:
277 return Person.from_name_email_string(person_obj)
278 except (ValueError, AttributeError):
279 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
281 try:
282 return Entity.from_name_email_string(person_obj)
283 except (ValueError, AttributeError):
284 logger.warning(f"Cannot convert {person_obj} to Entity.")
285 return None
287 def sync(self, metadata: ProjectMetadata) -> None:
288 """Sync metadata with pyproject.toml file."""
289 # Store original _from_person method
290 original_from_person = self._from_person
292 # Override _from_person to include poetry_version
293 self._from_person = lambda person: original_from_person( # type: ignore
294 person, poetry_version=self._poetry_version
295 )
297 # Call parent sync method
298 super().sync(metadata)
300 # Restore original _from_person method
301 self._from_person = original_from_person # type: ignore
303 if metadata.license:
304 self.license = license_expression(metadata.license)
306 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
307 if self._poetry_version == 2:
308 if (
309 "description" in self._data["project"]
310 and "\n" in self._data["project"]["description"]
311 ):
312 self._data["project"]["description"] = tomlkit.string(
313 self._data["project"]["description"], multiline=True
314 )
315 # Move urls section to the end if it exists
316 if "urls" in self._data["project"]:
317 urls = self._data["project"].pop("urls")
318 self._data["project"]["urls"] = urls
321class Pep621(PyprojectCommon):
322 """Handler for PEP 621 `[project]` metadata in pyproject.toml.
324 This covers every backend that stores its metadata in the standard
325 `[project]` table, i.e. uv, hatchling, flit, PDM, setuptools and
326 Poetry 2.x. Only Poetry 1.x needs its own handler, see [somesy.pyproject.writer.Poetry][].
327 """
329 def __init__(self, path: Path, pass_validation: bool | None = False):
330 """PEP 621 `[project]` config file handler parsed from pyproject.toml.
332 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
333 """
334 section = ["project"]
335 mappings = {
336 "homepage": ["urls", "homepage"],
337 "repository": ["urls", "repository"],
338 "documentation": ["urls", "documentation"],
339 }
340 super().__init__(
341 path,
342 section=section,
343 direct_mappings=mappings,
344 model_cls=Pep621Config,
345 pass_validation=pass_validation,
346 )
348 @staticmethod
349 def _from_person(person: Person | Entity):
350 """Convert project metadata person object to a PEP 621 person table."""
351 response = {"name": person.full_name}
352 if person.email:
353 response["email"] = person.email
354 return response
356 @staticmethod
357 def _to_person(person_obj: str | dict) -> Entity | Person | None:
358 """Parse a PEP 621 person entry to a Person/Entity."""
359 # NOTE: for our purposes, does not matter what are given or family names,
360 # we only compare on full_name anyway.
361 if isinstance(person_obj, dict):
362 temp = str(person_obj["name"])
363 if "email" in person_obj:
364 temp = f"{temp} <{person_obj['email']}>"
365 person_obj = temp
367 try:
368 return Person.from_name_email_string(person_obj)
369 except (ValueError, AttributeError):
370 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
372 try:
373 return Entity.from_name_email_string(person_obj)
374 except (ValueError, AttributeError):
375 logger.warning(f"Cannot convert {person_obj} to Entity.")
376 return None
378 def sync(self, metadata: ProjectMetadata) -> None:
379 """Sync metadata with pyproject.toml file and fix license field."""
380 super().sync(metadata)
381 if metadata.license:
382 self.license = license_expression(metadata.license)
385def _builds_with_poetry(data: Any) -> bool:
386 """Return whether the project declares a Poetry build backend.
388 Only used to tell a Poetry 2.x project apart from a project of another
389 backend that kept a `[tool.poetry]` section for its dependencies, for
390 example while migrating away from Poetry. Without a build backend we
391 cannot tell, and assume Poetry as before.
392 """
393 backend = data.get("build-system", {}).get("build-backend")
394 return not backend or "poetry" in str(backend)
397# ----
400class Pyproject(wrapt.ObjectProxy):
401 """Class for syncing pyproject file with other metadata files."""
403 __wrapped__: Pep621 | Poetry
405 def __init__(self, path: Path, pass_validation: bool | None = False):
406 """Pyproject wrapper class. Wraps either PEP 621 `[project]` or Poetry metadata.
408 The handler is picked based on the metadata tables present in the file,
409 not on the configured build backend.
411 Args:
412 path (Path): Path to pyproject.toml file.
413 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
415 Raises:
416 FileNotFoundError: Raised when pyproject.toml file is not found.
417 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
419 """
420 data = None
421 if not path.is_file():
422 raise FileNotFoundError(f"pyproject file {path} not found")
424 with open(path, "r") as f:
425 data = load(f)
427 # inspect file to pick suitable project metadata writer
428 is_poetry = "tool" in data and "poetry" in data["tool"]
429 has_project = "project" in data
431 if is_poetry and has_project and not _builds_with_poetry(data):
432 # another backend builds the project, so it reads the metadata from
433 # [project] and what remains in [tool.poetry] is only configuration
434 logger.log(
435 VERBOSE,
436 "Ignoring the tool.poetry section, the project is not built with Poetry",
437 )
438 is_poetry = False
440 if is_poetry:
441 if has_project:
442 logger.log(
443 VERBOSE,
444 "Found Poetry 2.x metadata with project section in pyproject.toml",
445 )
446 else:
447 logger.log(VERBOSE, "Found Poetry 1.x metadata in pyproject.toml")
448 self.__wrapped__ = Poetry(
449 path, pass_validation=pass_validation, version=2 if has_project else 1
450 )
451 elif has_project and not is_poetry:
452 # brackets are escaped, the log handler renders rich markup
453 logger.log(VERBOSE, "Found PEP 621 \\[project] metadata in pyproject.toml")
454 self.__wrapped__ = Pep621(path, pass_validation=pass_validation)
455 else:
456 msg = (
457 "The pyproject.toml file is ambiguous. Ensure it has either a PEP 621 "
458 "[project] section (uv, hatchling, flit, PDM, setuptools, Poetry 2.x) "
459 "or a [tool.poetry] section (Poetry 1.x)."
460 )
461 raise ValueError(msg)
463 super().__init__(self.__wrapped__)