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