Coverage for src/somesy/pyproject/writer.py: 93%
91 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-29 07:50 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-29 07:50 +0000
1"""Pyproject writers for setuptools and poetry."""
3import logging
4from pathlib import Path
5from typing import Any, List, Optional, Union
7import tomlkit
8import wrapt
9from rich.pretty import pretty_repr
10from tomlkit import load
12from somesy.core.models import Person, ProjectMetadata
13from somesy.core.writer import IgnoreKey, ProjectMetadataWriter
15from .models import PoetryConfig, SetuptoolsConfig
17logger = logging.getLogger("somesy")
20class PyprojectCommon(ProjectMetadataWriter):
21 """Poetry config file handler parsed from pyproject.toml."""
23 def __init__(
24 self, path: Path, *, section: List[str], model_cls, direct_mappings=None
25 ):
26 """Poetry config file handler parsed from pyproject.toml.
28 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
29 """
30 self._model_cls = model_cls
31 self._section = section
32 super().__init__(
33 path, create_if_not_exists=False, direct_mappings=direct_mappings or {}
34 )
36 def _load(self) -> None:
37 """Load pyproject.toml file."""
38 with open(self.path) as f:
39 self._data = tomlkit.load(f)
41 def _validate(self) -> None:
42 """Validate poetry config using pydantic class.
44 In order to preserve toml comments and structure, tomlkit library is used.
45 Pydantic class only used for validation.
46 """
47 config = dict(self._get_property([]))
48 logger.debug(
49 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}"
50 )
51 self._model_cls(**config)
53 def save(self, path: Optional[Path] = None) -> None:
54 """Save the pyproject file."""
55 path = path or self.path
56 with open(path, "w") as f:
57 tomlkit.dump(self._data, f)
59 def _get_property(
60 self, key: Union[str, List[str]], *, remove: bool = False, **kwargs
61 ) -> Optional[Any]:
62 """Get a property from the pyproject.toml file."""
63 key_path = [key] if isinstance(key, str) else key
64 full_path = self._section + key_path
65 return super()._get_property(full_path, remove=remove, **kwargs)
67 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None:
68 """Set a property in the pyproject.toml file."""
69 if isinstance(key, IgnoreKey):
70 return
71 key_path = [key] if isinstance(key, str) else key
73 if not value: # remove value and clean up the sub-dict
74 self._get_property(key_path, remove=True)
75 return
77 # get the tomlkit object of the section
78 dat = self._get_property([])
80 # dig down, create missing nested objects on the fly
81 curr = dat
82 for key in key_path[:-1]:
83 if key not in curr:
84 curr.add(key, tomlkit.table())
85 curr = curr[key]
86 curr[key_path[-1]] = value
89class Poetry(PyprojectCommon):
90 """Poetry config file handler parsed from pyproject.toml."""
92 def __init__(self, path: Path):
93 """Poetry config file handler parsed from pyproject.toml.
95 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
96 """
97 super().__init__(path, section=["tool", "poetry"], model_cls=PoetryConfig)
99 @staticmethod
100 def _from_person(person: Person):
101 """Convert project metadata person object to poetry string for person format "full name <email>."""
102 return person.to_name_email_string()
104 @staticmethod
105 def _to_person(person_obj: str) -> Optional[Person]:
106 """Parse poetry person string to a Person."""
107 try:
108 return Person.from_name_email_string(person_obj)
109 except (ValueError, AttributeError):
110 logger.warning(f"Cannot convert {person_obj} to Person object.")
111 return None
114class SetupTools(PyprojectCommon):
115 """Setuptools config file handler parsed from setup.cfg."""
117 def __init__(self, path: Path):
118 """Setuptools config file handler parsed from pyproject.toml.
120 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
121 """
122 section = ["project"]
123 mappings = {
124 "homepage": ["urls", "homepage"],
125 "repository": ["urls", "repository"],
126 "documentation": ["urls", "documentation"],
127 "license": ["license", "text"],
128 }
129 super().__init__(
130 path, section=section, direct_mappings=mappings, model_cls=SetuptoolsConfig
131 )
133 @staticmethod
134 def _from_person(person: Person):
135 """Convert project metadata person object to setuptools dict for person format."""
136 return {"name": person.full_name, "email": person.email}
138 @staticmethod
139 def _to_person(person_obj) -> Person:
140 """Parse setuptools person string to a Person."""
141 # NOTE: for our purposes, does not matter what are given or family names,
142 # we only compare on full_name anyway.
143 names = list(map(lambda s: s.strip(), person_obj["name"].split()))
144 return Person(
145 **{
146 "given-names": " ".join(names[:-1]),
147 "family-names": names[-1],
148 "email": person_obj["email"].strip(),
149 }
150 )
152 def sync(self, metadata: ProjectMetadata) -> None:
153 """Sync metadata with pyproject.toml file and fix license field."""
154 super().sync(metadata)
156 # if license field has both text and file, remove file
157 if (
158 self._get_property(["license", "file"]) is not None
159 and self._get_property(["license", "text"]) is not None
160 ):
161 # delete license file property
162 self._data["project"]["license"].pop("file")
165# ----
168class Pyproject(wrapt.ObjectProxy):
169 """Class for syncing pyproject file with other metadata files."""
171 __wrapped__: Union[SetupTools, Poetry]
173 def __init__(self, path: Path):
174 """Pyproject wrapper class. Wraps either setuptools or poetry.
176 Args:
177 path (Path): Path to pyproject.toml file.
179 Raises:
180 FileNotFoundError: Raised when pyproject.toml file is not found.
181 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
183 """
184 data = None
185 if not path.is_file():
186 raise FileNotFoundError(f"pyproject file {path} not found")
188 with open(path, "r") as f:
189 data = load(f)
191 # inspect file to pick suitable project metadata writer
192 if "project" in data:
193 logger.verbose("Found setuptools-based metadata in pyproject.toml")
194 self.__wrapped__ = SetupTools(path)
195 elif "tool" in data and "poetry" in data["tool"]:
196 logger.verbose("Found poetry-based metadata in pyproject.toml")
197 self.__wrapped__ = Poetry(path)
198 else:
199 msg = "The pyproject.toml file is ambiguous, either add a [project] or [tool.poetry] section"
200 raise ValueError(msg)
202 super().__init__(self.__wrapped__)