Coverage for src/somesy/pyproject/writer.py: 100%
81 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-10 14:33 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-10 14:33 +0000
1"""Pyproject writers for setuptools and poetry."""
2import logging
3import re
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
13from somesy.core.writer import 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(self, key: Union[str, List[str]]) -> Optional[Any]:
60 """Get a property from the pyproject.toml file."""
61 key_path = [key] if isinstance(key, str) else key
62 full_path = self._section + key_path
63 return super()._get_property(full_path)
65 def _set_property(self, key: Union[str, List[str]], value: Any) -> None:
66 """Set a property in the pyproject.toml file."""
67 key_path = [key] if isinstance(key, str) else key
68 # get the tomlkit object of the section
69 dat = self._get_property([])
70 # dig down, create missing nested objects on the fly
71 curr = dat
72 for key in key_path[:-1]:
73 if key not in curr:
74 curr.add(key, tomlkit.table())
75 curr = curr[key]
76 curr[key_path[-1]] = value
79class Poetry(PyprojectCommon):
80 """Poetry config file handler parsed from pyproject.toml."""
82 def __init__(self, path: Path):
83 """Poetry config file handler parsed from pyproject.toml.
85 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
86 """
87 super().__init__(path, section=["tool", "poetry"], model_cls=PoetryConfig)
89 @staticmethod
90 def _from_person(person: Person):
91 """Convert project metadata person object to poetry string for person format "full name <email>."""
92 return f"{person.full_name} <{person.email}>"
94 @staticmethod
95 def _to_person(person_obj: str) -> Person:
96 """Parse poetry person string to a Person."""
97 m = re.match(r"\s*([^<]+)<([^>]+)>", person_obj)
98 names, mail = (
99 list(map(lambda s: s.strip(), m.group(1).split())),
100 m.group(2).strip(),
101 )
102 # NOTE: for our purposes, does not matter what are given or family names,
103 # we only compare on full_name anyway.
104 return Person(
105 **{
106 "given-names": " ".join(names[:-1]),
107 "family-names": names[-1],
108 "email": mail,
109 }
110 )
113class SetupTools(PyprojectCommon):
114 """Setuptools config file handler parsed from setup.cfg."""
116 def __init__(self, path: Path):
117 """Setuptools config file handler parsed from pyproject.toml.
119 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
120 """
121 section = ["project"]
122 mappings = {
123 "homepage": ["urls", "homepage"],
124 "repository": ["urls", "repository"],
125 }
126 super().__init__(
127 path, section=section, direct_mappings=mappings, model_cls=SetuptoolsConfig
128 )
130 @staticmethod
131 def _from_person(person: Person):
132 """Convert project metadata person object to setuptools dict for person format."""
133 return {"name": person.full_name, "email": person.email}
135 @staticmethod
136 def _to_person(person_obj) -> Person:
137 """Parse setuptools person string to a Person."""
138 # NOTE: for our purposes, does not matter what are given or family names,
139 # we only compare on full_name anyway.
140 names = list(map(lambda s: s.strip(), person_obj["name"].split()))
141 return Person(
142 **{
143 "given-names": " ".join(names[:-1]),
144 "family-names": names[-1],
145 "email": person_obj["email"].strip(),
146 }
147 )
150# ----
153class Pyproject(wrapt.ObjectProxy):
154 """Class for syncing pyproject file with other metadata files."""
156 __wrapped__: Union[SetupTools, Poetry]
158 def __init__(self, path: Path):
159 """Pyproject wrapper class. Wraps either setuptools or poetry.
161 Args:
162 path (Path): Path to pyproject.toml file.
164 Raises:
165 FileNotFoundError: Raised when pyproject.toml file is not found.
166 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file.
167 """
168 data = None
169 if not path.is_file():
170 raise FileNotFoundError(f"pyproject file {path} not found")
172 with open(path, "r") as f:
173 data = load(f)
175 # inspect file to pick suitable project metadata writer
176 if "project" in data:
177 logger.verbose("Found setuptools-based metadata in pyproject.toml")
178 self.__wrapped__ = SetupTools(path)
179 elif "tool" in data and "poetry" in data["tool"]:
180 logger.verbose("Found poetry-based metadata in pyproject.toml")
181 self.__wrapped__ = Poetry(path)
182 else:
183 msg = "The pyproject.toml file is ambiguous, either add a [project] or [tool.poetry] section"
184 raise ValueError(msg)
186 super().__init__(self.__wrapped__)