Coverage for src/somesy/pyproject/writer.py: 91%
223 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 10:45 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 10:45 +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, Table
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 self._created_tables: list[Table] = []
60 super().__init__(
61 path,
62 create_if_not_exists=False,
63 direct_mappings=direct_mappings or {},
64 pass_validation=pass_validation,
65 )
66 self._adopt_url_key_spelling()
68 def _adopt_url_key_spelling(self) -> None:
69 """Point the url mappings at the key spellings used in the file.
71 The common project templates capitalize the `[project.urls]` keys
72 ("Homepage", "Bug Tracker"). Without this, somesy would neither read
73 those entries nor update them, and would instead write a second,
74 differently spelled entry next to them. A key somesy adds follows the
75 capitalization of the keys already there, to keep the table uniform.
76 """
77 keys = list(self._get_property(["urls"]) or {})
78 existing = {normalize_url_key(key): key for key in keys}
79 capitalized = bool(keys) and all(key[:1].isupper() for key in keys)
80 for field, key_path in self.direct_mappings.items():
81 if not isinstance(key_path, list) or key_path[:1] != ["urls"]:
82 continue
83 name = key_path[-1]
84 spelling = existing.get(normalize_url_key(name))
85 if spelling is None and capitalized:
86 spelling = name.capitalize()
87 if spelling is not None:
88 self.direct_mappings[field] = ["urls", spelling]
90 @property
91 def _dynamic_fields(self) -> list[str]:
92 """Return the list of fields marked as dynamic in pyproject.toml."""
93 return self._get_property(["dynamic"]) or []
95 @property
96 def version(self) -> str | None:
97 """Return the version of the project."""
98 return self._get_property(self._get_key("version"))
100 @version.setter
101 def version(self, version: str | None) -> None:
102 """Set version, skipping if listed as dynamic."""
103 if "version" in self._dynamic_fields:
104 if version:
105 logger.warning(
106 "Field 'version' is listed as dynamic — skipping sync from somesy."
107 )
108 return
109 self._set_property(self._get_key("version"), version)
111 @property
112 def license(self) -> str | None:
113 """Return the license of the project as an SPDX expression.
115 PEP 639 replaced the `license = { text = ... }` table with a plain
116 expression, but files predating it are still valid and must be read.
117 A `{ file = ... }` table names a license file instead of an
118 identifier, so there is no expression to report.
119 """
120 license = self._get_property(["license"])
121 if isinstance(license, dict):
122 return license.get("text")
123 return license
125 @license.setter
126 def license(self, license: str | None) -> None:
127 """Set the license of the project."""
128 self._set_property(["license"], license)
130 @property
131 def description(self) -> str | None:
132 """Return the description of the project."""
133 return self._get_property(self._get_key("description"))
135 @description.setter
136 def description(self, description: str) -> None:
137 """Set description, skipping if listed as dynamic."""
138 if "description" in self._dynamic_fields:
139 if description:
140 logger.warning(
141 "Field 'description' is listed as dynamic — skipping sync from somesy."
142 )
143 return
144 self._set_property(self._get_key("description"), description)
146 def _load(self) -> None:
147 """Load pyproject.toml file."""
148 with open(self.path) as f:
149 self._data = tomlkit.load(f)
151 def _validate(self) -> None:
152 """Validate poetry config using pydantic class.
154 In order to preserve toml comments and structure, tomlkit library is used.
155 Pydantic class only used for validation.
156 """
157 if self.pass_validation:
158 return
159 config = dict(self._get_property([]))
160 logger.debug(
161 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
162 )
163 self._model_cls(**config)
165 def save(self, path: Path | None = None) -> None:
166 """Save the pyproject file."""
167 path = path or self.path
169 with open(path, "w") as f:
170 tomlkit.dump(self._data, f)
172 def _get_property(
173 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs
174 ) -> Any:
175 """Get a property from the pyproject.toml file."""
176 if isinstance(key, IgnoreKey):
177 return None
178 key_path = [key] if isinstance(key, str) else key
179 full_path = self._section + key_path
180 return super()._get_property(full_path, remove=remove, **kwargs)
182 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None:
183 """Set a property in the pyproject.toml file."""
184 if isinstance(key, IgnoreKey):
185 return
186 key_path = [key] if isinstance(key, str) else key
188 if not value: # remove value and clean up the sub-dict
189 self._get_property(key_path, remove=True)
190 return
192 # get the tomlkit object of the section
193 dat = self._get_property([])
195 # dig down, create missing nested objects on the fly
196 curr = dat
197 for path_key in key_path[:-1]:
198 if path_key not in curr:
199 table = tomlkit.table()
200 curr.add(path_key, table)
201 self._created_tables.append(table)
202 curr = curr[path_key]
204 # Handle arrays with proper formatting
205 if isinstance(value, list):
206 array = tomlkit.array()
207 array.extend(value)
208 array.multiline(True)
209 # Ensure whitespace after commas in inline tables
210 for item in array:
211 if isinstance(item, InlineTable):
212 # Rebuild the inline table with desired formatting
213 formatted_item = tomlkit.inline_table()
214 for k, v in item.value.items():
215 formatted_item[k] = v
216 formatted_item.trivia.trail = " " # Add space after each comma
217 array[array.index(item)] = formatted_item
218 curr[key_path[-1]] = array
219 else:
220 curr[key_path[-1]] = value
222 if any(curr is table for table in self._created_tables):
223 for field in curr:
224 curr.item(field).trivia.trail = "\n"
225 curr.item(list(curr)[-1]).trivia.trail = "\n\n"
228class Poetry(PyprojectCommon):
229 """Poetry config file handler parsed from pyproject.toml."""
231 def __init__(
232 self,
233 path: Path,
234 pass_validation: bool | None = False,
235 version: int | None = 1,
236 ):
237 """Poetry config file handler parsed from pyproject.toml.
239 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
240 """
241 self._poetry_version = version or 1
242 v2_mappings = {
243 "homepage": ["urls", "homepage"],
244 "repository": ["urls", "repository"],
245 "documentation": ["urls", "documentation"],
246 }
247 if version == 1:
248 super().__init__(
249 path,
250 section=["tool", "poetry"],
251 model_cls=PoetryConfig,
252 pass_validation=pass_validation,
253 )
254 else:
255 super().__init__(
256 path,
257 section=["project"],
258 model_cls=PoetryConfig,
259 pass_validation=pass_validation,
260 direct_mappings=v2_mappings,
261 )
263 @staticmethod
264 def _from_person(person: Person | Entity, poetry_version: int = 1):
265 """Convert project metadata person object to poetry string for person format "full name <email>."""
266 if poetry_version == 1:
267 return person.to_name_email_string()
268 else:
269 response = {"name": person.full_name}
270 if person.email:
271 response["email"] = person.email
272 return response
274 @staticmethod
275 def _to_person(
276 person_obj: str | dict[str, str],
277 ) -> Person | Entity | None:
278 """Convert from free string to person or entity object."""
279 if isinstance(person_obj, dict):
280 temp = str(person_obj["name"])
281 if "email" in person_obj:
282 temp = f"{temp} <{person_obj['email']}>"
283 person_obj = temp
284 try:
285 return Person.from_name_email_string(person_obj)
286 except (ValueError, AttributeError):
287 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
289 try:
290 return Entity.from_name_email_string(person_obj)
291 except (ValueError, AttributeError):
292 logger.warning(f"Cannot convert {person_obj} to Entity.")
293 return None
295 def sync(self, metadata: ProjectMetadata) -> None:
296 """Sync metadata with pyproject.toml file."""
297 # Store original _from_person method
298 original_from_person = self._from_person
300 # Override _from_person to include poetry_version
301 self._from_person = lambda person: original_from_person( # type: ignore
302 person, poetry_version=self._poetry_version
303 )
305 # Call parent sync method
306 super().sync(metadata)
308 # Restore original _from_person method
309 self._from_person = original_from_person # type: ignore
311 if metadata.license:
312 self.license = license_expression(metadata.license)
314 # For Poetry v2, convert authors and maintainers from array of tables to inline tables
315 if self._poetry_version == 2:
316 if (
317 "description" in self._data["project"]
318 and "\n" in self._data["project"]["description"]
319 ):
320 self._data["project"]["description"] = tomlkit.string(
321 self._data["project"]["description"], multiline=True
322 )
323 # Move urls section to the end if it exists
324 if "urls" in self._data["project"]:
325 urls = self._data["project"].pop("urls")
326 self._data["project"]["urls"] = urls
329class Pep621(PyprojectCommon):
330 """Handler for PEP 621 `[project]` metadata in pyproject.toml.
332 This covers every backend that stores its metadata in the standard
333 `[project]` table, i.e. uv, hatchling, flit, PDM, setuptools and
334 Poetry 2.x. Only Poetry 1.x needs its own handler, see [somesy.pyproject.writer.Poetry][].
335 """
337 def __init__(self, path: Path, pass_validation: bool | None = False):
338 """PEP 621 `[project]` config file handler parsed from pyproject.toml.
340 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
341 """
342 section = ["project"]
343 mappings = {
344 "homepage": ["urls", "homepage"],
345 "repository": ["urls", "repository"],
346 "documentation": ["urls", "documentation"],
347 }
348 super().__init__(
349 path,
350 section=section,
351 direct_mappings=mappings,
352 model_cls=Pep621Config,
353 pass_validation=pass_validation,
354 )
356 @staticmethod
357 def _from_person(person: Person | Entity):
358 """Convert project metadata person object to a PEP 621 person table."""
359 response = {"name": person.full_name}
360 if person.email:
361 response["email"] = person.email
362 return response
364 @staticmethod
365 def _to_person(person_obj: str | dict) -> Entity | Person | None:
366 """Parse a PEP 621 person entry to a Person/Entity."""
367 # NOTE: for our purposes, does not matter what are given or family names,
368 # we only compare on full_name anyway.
369 if isinstance(person_obj, dict):
370 temp = str(person_obj["name"])
371 if "email" in person_obj:
372 temp = f"{temp} <{person_obj['email']}>"
373 person_obj = temp
375 try:
376 return Person.from_name_email_string(person_obj)
377 except (ValueError, AttributeError):
378 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.")
380 try:
381 return Entity.from_name_email_string(person_obj)
382 except (ValueError, AttributeError):
383 logger.warning(f"Cannot convert {person_obj} to Entity.")
384 return None
386 def sync(self, metadata: ProjectMetadata) -> None:
387 """Sync metadata with pyproject.toml file and fix license field."""
388 super().sync(metadata)
389 if metadata.license:
390 self.license = license_expression(metadata.license)
393def _builds_with_poetry(data: Any) -> bool:
394 """Return whether the project declares a Poetry build backend.
396 Only used to tell a Poetry 2.x project apart from a project of another
397 backend that kept a `[tool.poetry]` section for its dependencies, for
398 example while migrating away from Poetry. Without a build backend we
399 cannot tell, and assume Poetry as before.
400 """
401 backend = data.get("build-system", {}).get("build-backend")
402 return not backend or "poetry" in str(backend)
405# ----
408class Pyproject(wrapt.ObjectProxy):
409 """Class for syncing pyproject file with other metadata files."""
411 __wrapped__: Pep621 | Poetry
413 def __init__(self, path: Path, pass_validation: bool | None = False):
414 """Pyproject wrapper class. Wraps either PEP 621 `[project]` or Poetry metadata.
416 The handler is picked based on the metadata tables present in the file,
417 not on the configured build backend.
419 Args:
420 path (Path): Path to pyproject.toml file.
421 pass_validation (bool, optional): Whether to pass validation. Defaults to False.
423 Raises:
424 FileNotFoundError: Raised when pyproject.toml file is not found.
425 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
427 """
428 data = None
429 if not path.is_file():
430 raise FileNotFoundError(f"pyproject file {path} not found")
432 with open(path, "r") as f:
433 data = load(f)
435 # inspect file to pick suitable project metadata writer
436 is_poetry = "tool" in data and "poetry" in data["tool"]
437 has_project = "project" in data
439 if is_poetry and has_project and not _builds_with_poetry(data):
440 # another backend builds the project, so it reads the metadata from
441 # [project] and what remains in [tool.poetry] is only configuration
442 logger.log(
443 VERBOSE,
444 "Ignoring the tool.poetry section, the project is not built with Poetry",
445 )
446 is_poetry = False
448 if is_poetry:
449 if has_project:
450 logger.log(
451 VERBOSE,
452 "Found Poetry 2.x metadata with project section in pyproject.toml",
453 )
454 else:
455 logger.log(VERBOSE, "Found Poetry 1.x metadata in pyproject.toml")
456 self.__wrapped__ = Poetry(
457 path, pass_validation=pass_validation, version=2 if has_project else 1
458 )
459 elif has_project and not is_poetry:
460 # brackets are escaped, the log handler renders rich markup
461 logger.log(VERBOSE, "Found PEP 621 \\[project] metadata in pyproject.toml")
462 self.__wrapped__ = Pep621(path, pass_validation=pass_validation)
463 else:
464 msg = (
465 "The pyproject.toml file is ambiguous. Ensure it has either a PEP 621 "
466 "[project] section (uv, hatchling, flit, PDM, setuptools, Poetry 2.x) "
467 "or a [tool.poetry] section (Poetry 1.x)."
468 )
469 raise ValueError(msg)
471 super().__init__(self.__wrapped__)