Coverage for src/somesy/pyproject/writer.py: 96%
202 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +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 Table
14from somesy.core.log import VERBOSE
15from somesy.core.models import Entity, Person, ProjectMetadata, parse_name_email
16from somesy.core.writer import (
17 IgnoreKey,
18 ProjectMetadataWriter,
19 license_expression,
20 multiline_description,
21 toml_array,
22)
24from .models import Pep621Config, PoetryConfig
26logger = logging.getLogger("somesy")
28SUMMARY_MAX_LENGTH = 512
29"""Longest package summary (`description`) that PyPI accepts on upload."""
31SUMMARY_LINE_BREAK = re.compile(r"[\r\n]")
32"""Line breaks are not allowed in a package summary (core metadata `Summary`)."""
35def normalize_url_key(name: str) -> str:
36 """Return a `[project.urls]` key in a form that can be compared.
38 PEP 621 does not standardize these key names, so the same URL appears as
39 "Bug Tracker", "bug-tracker" or "bugtracker" depending on the template the
40 project started from.
41 """
42 return re.sub(r"[\s_-]+", "", name).lower()
45class PyprojectCommon(ProjectMetadataWriter):
46 """Poetry config file handler parsed from pyproject.toml."""
48 def __init__(
49 self,
50 path: Path,
51 *,
52 section: list[str],
53 model_cls,
54 direct_mappings=None,
55 pass_validation: bool | None = False,
56 ):
57 """Poetry config file handler parsed from pyproject.toml.
59 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
60 """
61 self._model_cls = model_cls
62 self._section = section
63 self._created_tables: list[Table] = []
64 super().__init__(
65 path,
66 create_if_not_exists=False,
67 direct_mappings=direct_mappings or {},
68 pass_validation=pass_validation,
69 )
70 self._adopt_url_key_spelling()
72 def _adopt_url_key_spelling(self) -> None:
73 """Point the url mappings at the key spellings used in the file.
75 The common project templates capitalize the `[project.urls]` keys
76 ("Homepage", "Bug Tracker"). Without this, somesy would neither read
77 those entries nor update them, and would instead write a second,
78 differently spelled entry next to them. A key somesy adds follows the
79 capitalization of the keys already there, to keep the table uniform.
80 """
81 keys = list(self._get_property(["urls"]) or {})
82 existing = {normalize_url_key(key): key for key in keys}
83 capitalized = bool(keys) and all(key[:1].isupper() for key in keys)
84 for field, key_path in self.direct_mappings.items():
85 if not isinstance(key_path, list) or key_path[:1] != ["urls"]:
86 continue
87 name = key_path[-1]
88 spelling = existing.get(normalize_url_key(name))
89 if spelling is None and capitalized:
90 spelling = name.capitalize()
91 if spelling is not None:
92 self.direct_mappings[field] = ["urls", spelling]
94 @property
95 def _dynamic_fields(self) -> list[str]:
96 """Return the list of fields marked as dynamic in pyproject.toml."""
97 return self._get_property(["dynamic"]) or []
99 @property
100 def version(self) -> str | None:
101 """Return the version of the project."""
102 return self._get_property(self._get_key("version"))
104 @version.setter
105 def version(self, version: str | None) -> None:
106 """Set version, skipping if listed as dynamic."""
107 if "version" in self._dynamic_fields:
108 if version:
109 logger.warning(
110 "Field 'version' is listed as dynamic — skipping sync from somesy."
111 )
112 return
113 self._set_property(self._get_key("version"), version)
115 @property
116 def license(self) -> str | None:
117 """Return the license of the project as an SPDX expression.
119 PEP 639 replaced the `license = { text = ... }` table with a plain
120 expression, but files predating it are still valid and must be read.
121 A `{ file = ... }` table names a license file instead of an
122 identifier, so there is no expression to report.
123 """
124 license = self._get_property(["license"])
125 if isinstance(license, dict):
126 return license.get("text")
127 return license
129 @license.setter
130 def license(self, license: str | None) -> None:
131 """Set the license of the project."""
132 self._set_property(["license"], license)
134 @property
135 def description(self) -> str | None:
136 """Return the description of the project."""
137 return self._get_property(self._get_key("description"))
139 @description.setter
140 def description(self, description: str) -> None:
141 """Set description, skipping if listed as dynamic."""
142 if "description" in self._dynamic_fields:
143 if description:
144 logger.warning(
145 "Field 'description' is listed as dynamic — skipping sync from somesy."
146 )
147 return
148 if description and (
149 len(description) > SUMMARY_MAX_LENGTH
150 or SUMMARY_LINE_BREAK.search(description)
151 ):
152 logger.warning(
153 "Field 'description' becomes the package summary, which must be a "
154 "single line of at most %d characters to be uploaded to PyPI. "
155 "Put a longer text in the 'abstract' field instead.",
156 SUMMARY_MAX_LENGTH,
157 )
158 self._set_property(self._get_key("description"), description)
160 def _load(self) -> None:
161 """Load pyproject.toml file."""
162 with open(self.path, encoding="utf-8") as f:
163 self._data = tomlkit.load(f)
165 def _validate(self) -> None:
166 """Validate poetry config using pydantic class.
168 In order to preserve toml comments and structure, tomlkit library is used.
169 Pydantic class only used for validation.
170 """
171 if self.pass_validation:
172 return
173 config = dict(self._get_property([]))
174 logger.debug(
175 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
176 )
177 self._model_cls(**config)
179 def save(self, path: Path | None = None) -> None:
180 """Save the pyproject file."""
181 path = path or self.path
183 with open(path, "w", encoding="utf-8") as f:
184 tomlkit.dump(self._data, f)
186 def _get_property(
187 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs
188 ) -> Any:
189 """Get a property from the pyproject.toml file."""
190 if isinstance(key, IgnoreKey):
191 return None
192 key_path = [key] if isinstance(key, str) else key
193 full_path = self._section + key_path
194 return super()._get_property(full_path, remove=remove, **kwargs)
196 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None:
197 """Set a property in the pyproject.toml file."""
198 if isinstance(key, IgnoreKey):
199 return
200 key_path = [key] if isinstance(key, str) else key
202 if not value: # remove value and clean up the sub-dict
203 self._get_property(key_path, remove=True)
204 return
206 # get the tomlkit object of the section
207 dat = self._get_property([])
209 # dig down, create missing nested objects on the fly
210 curr = dat
211 for path_key in key_path[:-1]:
212 if path_key not in curr:
213 table = tomlkit.table()
214 curr.add(path_key, table)
215 self._created_tables.append(table)
216 curr = curr[path_key]
218 # Handle arrays with proper formatting
219 if isinstance(value, list):
220 array = toml_array(value)
221 curr[key_path[-1]] = array
222 else:
223 curr[key_path[-1]] = value
225 if any(curr is table for table in self._created_tables):
226 for field in curr:
227 curr.item(field).trivia.trail = "\n"
228 curr.item(list(curr)[-1]).trivia.trail = "\n\n"
231class Poetry(PyprojectCommon):
232 """Poetry config file handler parsed from pyproject.toml."""
234 def __init__(
235 self,
236 path: Path,
237 pass_validation: bool | None = False,
238 version: int | None = 1,
239 ):
240 """Poetry config file handler parsed from pyproject.toml.
242 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
243 """
244 version = version or 1
245 self._poetry_version = version
246 v2_mappings = {
247 "homepage": ["urls", "homepage"],
248 "repository": ["urls", "repository"],
249 "documentation": ["urls", "documentation"],
250 }
251 if version == 1:
252 super().__init__(
253 path,
254 section=["tool", "poetry"],
255 model_cls=PoetryConfig,
256 pass_validation=pass_validation,
257 )
258 else:
259 super().__init__(
260 path,
261 section=["project"],
262 model_cls=PoetryConfig,
263 pass_validation=pass_validation,
264 direct_mappings=v2_mappings,
265 )
267 @staticmethod
268 def _from_person(person: Person | Entity, poetry_version: int = 1):
269 """Convert project metadata person object to poetry string for person format "full name <email>."""
270 if poetry_version == 1:
271 return person.to_name_email_string()
272 else:
273 return _pep621_person(person)
275 @staticmethod
276 def _to_person(
277 person_obj: str | dict[str, str],
278 ) -> Person | Entity | None:
279 """Convert from free string to person or entity object."""
280 if isinstance(person_obj, dict):
281 temp = str(person_obj["name"])
282 if "email" in person_obj:
283 temp = f"{temp} <{person_obj['email']}>"
284 person_obj = temp
285 return parse_name_email(person_obj)
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 try:
298 # Call parent sync method
299 super().sync(metadata)
300 finally:
301 # Restore original _from_person method
302 self._from_person = original_from_person # type: ignore
304 if metadata.license:
305 self.license = license_expression(metadata.license)
307 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
308 if self._poetry_version == 2:
309 multiline_description(self._data["project"])
310 # Move urls section to the end if it exists
311 if "urls" in self._data["project"]:
312 urls = self._data["project"].pop("urls")
313 self._data["project"]["urls"] = urls
316class Pep621(PyprojectCommon):
317 """Handler for PEP 621 `[project]` metadata in pyproject.toml.
319 This covers every backend that stores its metadata in the standard
320 `[project]` table, i.e. uv, hatchling, flit, PDM, setuptools and
321 Poetry 2.x. Only Poetry 1.x needs its own handler, see [somesy.pyproject.writer.Poetry][].
322 """
324 def __init__(self, path: Path, pass_validation: bool | None = False):
325 """PEP 621 `[project]` config file handler parsed from pyproject.toml.
327 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
328 """
329 section = ["project"]
330 mappings = {
331 "homepage": ["urls", "homepage"],
332 "repository": ["urls", "repository"],
333 "documentation": ["urls", "documentation"],
334 }
335 super().__init__(
336 path,
337 section=section,
338 direct_mappings=mappings,
339 model_cls=Pep621Config,
340 pass_validation=pass_validation,
341 )
343 @staticmethod
344 def _from_person(person: Person | Entity):
345 """Convert project metadata person object to a PEP 621 person table."""
346 return _pep621_person(person)
348 @staticmethod
349 def _to_person(person_obj: str | dict) -> Entity | Person | None:
350 """Parse a PEP 621 person entry to a Person/Entity."""
351 # NOTE: for our purposes, does not matter what are given or family names,
352 # we only compare on full_name anyway.
353 if isinstance(person_obj, dict):
354 temp = str(person_obj["name"])
355 if "email" in person_obj:
356 temp = f"{temp} <{person_obj['email']}>"
357 person_obj = temp
359 return parse_name_email(person_obj)
361 def sync(self, metadata: ProjectMetadata) -> None:
362 """Sync metadata with pyproject.toml file and fix license field."""
363 super().sync(metadata)
364 if metadata.license:
365 self.license = license_expression(metadata.license)
368def _pep621_person(person: Person | Entity) -> dict[str, str]:
369 """Convert a person/entity to a PEP 621 person table."""
370 response = {"name": person.full_name}
371 if person.email:
372 response["email"] = person.email
373 return response
376def _builds_with_poetry(data: Any) -> bool:
377 """Return whether the project declares a Poetry build backend.
379 Only used to tell a Poetry 2.x project apart from a project of another
380 backend that kept a `[tool.poetry]` section for its dependencies, for
381 example while migrating away from Poetry. Without a build backend we
382 cannot tell, and assume Poetry as before.
383 """
384 backend = data.get("build-system", {}).get("build-backend")
385 return not backend or "poetry" in str(backend)
388# ----
391class Pyproject(wrapt.ObjectProxy):
392 """Class for syncing pyproject file with other metadata files."""
394 __wrapped__: Pep621 | Poetry
396 def __init__(self, path: Path, pass_validation: bool | None = False):
397 """Pyproject wrapper class. Wraps either PEP 621 `[project]` or Poetry metadata.
399 The handler is picked based on the metadata tables present in the file,
400 not on the configured build backend.
402 Args:
403 path (Path): Path to pyproject.toml file.
404 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
406 Raises:
407 FileNotFoundError: Raised when pyproject.toml file is not found.
408 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
410 """
411 data = None
412 if not path.is_file():
413 raise FileNotFoundError(f"pyproject file {path} not found")
415 with open(path, "r", encoding="utf-8") as f:
416 data = load(f)
418 # inspect file to pick suitable project metadata writer
419 is_poetry = "tool" in data and "poetry" in data["tool"]
420 has_project = "project" in data
422 if is_poetry and has_project and not _builds_with_poetry(data):
423 # another backend builds the project, so it reads the metadata from
424 # [project] and what remains in [tool.poetry] is only configuration
425 logger.log(
426 VERBOSE,
427 "Ignoring the tool.poetry section, the project is not built with Poetry",
428 )
429 is_poetry = False
431 if is_poetry:
432 if has_project:
433 logger.log(
434 VERBOSE,
435 "Found Poetry 2.x metadata with project section in pyproject.toml",
436 )
437 else:
438 logger.log(VERBOSE, "Found Poetry 1.x metadata in pyproject.toml")
439 self.__wrapped__ = Poetry(
440 path, pass_validation=pass_validation, version=2 if has_project else 1
441 )
442 elif has_project and not is_poetry:
443 # brackets are escaped, the log handler renders rich markup
444 logger.log(VERBOSE, "Found PEP 621 \\[project] metadata in pyproject.toml")
445 self.__wrapped__ = Pep621(path, pass_validation=pass_validation)
446 else:
447 msg = (
448 "The pyproject.toml file is ambiguous. Ensure it has either a PEP 621 "
449 "[project] section (uv, hatchling, flit, PDM, setuptools, Poetry 2.x) "
450 "or a [tool.poetry] section (Poetry 1.x)."
451 )
452 raise ValueError(msg)
454 super().__init__(self.__wrapped__)