forked from MousaZeidBaker/poetryup
-
Notifications
You must be signed in to change notification settings - Fork 0
/
pyproject.py
381 lines (322 loc) · 13.2 KB
/
pyproject.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
import logging
import re
from collections import defaultdict
from typing import Dict, List, Optional, Union
import tomlkit
from packaging import version as version_
from poetryup.core.cmd import cmd_run
from poetryup.models.dependency import Constraint, Dependency
class Pyproject:
"""A class to represent a pyproject.toml configuration file.
The pyproject.toml was defined in PEP 518 and expanded in PEP 621.
https://www.python.org/dev/peps/pep-0518/
https://www.python.org/dev/peps/pep-0621/
Args:
pyproject_str: The pyproject.toml file parsed as a string
"""
def __init__(self, pyproject_str: str) -> None:
self.pyproject = tomlkit.loads(pyproject_str)
self.poetry_version = version_.parse(self.__get_poetry_version())
self._dependencies = None # caches the dependencies
@property
def dependencies(self) -> List[Dependency]:
"""The pyproject dependencies"""
if self._dependencies is not None:
# return cached dependencies
return self._dependencies
dependencies: List[Dependency] = []
table = self.pyproject["tool"]["poetry"]
# get default dependencies
for name, version in table.get("dependencies", {}).items():
if name == "python":
# ignore python dependency
continue
dependency = Dependency(
name=name,
version=version,
group="default",
)
dependencies.append(dependency)
# get dev-dependencies
for name, version in table.get("dev-dependencies", {}).items():
dependency = Dependency(
name=name,
version=version,
group="dev",
)
dependencies.append(dependency)
# get dependencies organized in groups
for group, deps in table.get("group", {}).items():
for name, version in deps["dependencies"].items():
dependency = Dependency(
name=name,
version=version,
group=group,
)
dependencies.append(dependency)
self._dependencies = dependencies # cache dependencies
return dependencies
@property
def lock_dependencies(self) -> List[Dependency]:
"""The pyproject dependencies with their lock version"""
# run poetry show to get currently installed dependencies
output = self.__run_poetry_show()
# create dependencies from each line of the output
pattern = re.compile("^[a-zA-Z-]+")
lock_dependencies: List[Dependency] = []
for line in output.split("\n"):
if pattern.match(line) is None:
# not a matching line, continue to next
continue
# extract name and version
lock_name, lock_version, *_ = line.split()
# search for dependency in pyproject
dependency = self.search_dependency(self.dependencies, lock_name)
if dependency is None:
# dependency not found, continue to next
continue
lock_dependencies.append(
Dependency(
name=dependency.name,
version=lock_version,
group=dependency.group,
)
)
return lock_dependencies
@property
def bumped_dependencies(self) -> List[Dependency]:
"""The pyproject dependencies with their version bumped to lock version
Lock versions will be used if applicable. For instance, using the lock
version for a dependency that is specified with the inequality
constraint '!=x.y.z' would completely change its meaning.
"""
lock_dependencies = self.lock_dependencies
bumped_dependencies: List[Dependency] = []
for dependency in self.dependencies:
constraint = dependency.constraint
# check for dependencies whom version can't be bumped
if (
constraint == Constraint.MULTIPLE_CONSTRAINTS
or constraint == Constraint.MULTIPLE_REQUIREMENTS
or constraint == Constraint.WILDCARD
):
bumped_dependencies.append(dependency)
continue
# search for lock dependency
lock_dependency = self.search_dependency(
lock_dependencies,
dependency.name,
)
if lock_dependency is None:
# lock dependency not found, dependency stays unchanged
bumped_dependencies.append(dependency)
continue
bumped_version = None
if constraint == Constraint.CARET:
bumped_version = "^" + lock_dependency.version
elif constraint == Constraint.TILDE:
bumped_version = "~" + lock_dependency.version
elif constraint == Constraint.INEQUALITY:
version_str = ""
if isinstance(dependency.version, str):
version_str = dependency.version
elif isinstance(dependency.version, Dict):
version_str = dependency.version.get("version", "")
if version_str[:2] == ">=":
bumped_version = ">=" + lock_dependency.version
elif constraint == Constraint.EXACT:
bumped_version = lock_dependency.version
version = dependency.version
if bumped_version is None:
# bump version can't be determined, version stays unchanged
pass
elif isinstance(version, str):
version = bumped_version
elif (
isinstance(version, Dict) and version.get("version") is not None
):
version["version"] = bumped_version
bumped_dependencies.append(
Dependency(
name=dependency.name,
version=version,
group=dependency.group,
)
)
return bumped_dependencies
def dumps(self) -> str:
"""Dumps pyproject into a string."""
return tomlkit.dumps(self.pyproject)
def search_dependency(
self,
dependencies: List[Dependency],
name: str,
) -> Union[Dependency, None]:
"""Search for a dependency by name given a list of dependencies
Args:
dependencies: A list of dependencies to search in
name: Name of the dependency to search for
Returns:
A dependency if found, None if not found
"""
for dependency in dependencies:
if dependency.name == name or dependency.normalized_name == name:
return dependency
def filter_dependencies(
self,
dependencies: List[Dependency],
without_constraints: List[Constraint] = [],
names: List[str] = [],
exclude_names: List[str] = [],
groups: List[str] = [],
) -> List[Dependency]:
"""Search for a dependency by name given a list of dependencies
Args:
dependencies: A list of dependencies to filter
without_constraints: The dependency constraints to ignore
names: The dependency names to include
exclude_names: The dependency names to exclude
groups: The dependency groups to include
Returns:
A list of dependencies
"""
if without_constraints:
# remove deps whom constraint is in the provided constraint list
dependencies = [
x
for x in dependencies
if x.constraint not in without_constraints
]
if names:
# remove deps whom name is NOT in the provided name list
dependencies = [x for x in dependencies if x.name in names]
if exclude_names:
# remove deps whom name is in the provided exclude_names list
dependencies = [
x for x in dependencies if x.name not in exclude_names
]
if groups:
# remove deps whom group is NOT in the provided group list
dependencies = [x for x in dependencies if x.group in groups]
return dependencies
def update_dependencies(
self,
latest: bool = False,
without_constraints: List[Constraint] = [],
names: List[str] = [],
exclude_names: List[str] = [],
groups: List[str] = [],
) -> None:
"""Update dependencies and bump their version in pyproject
Args:
latest: Whether to update dependencies to their latest version
without_constraints: The dependency constraints to ignore
names: The dependency names to include
exclude_names: The dependency names to exclude
groups: The dependency groups to include
"""
if latest:
logging.info("Updating dependencies to their latest version")
dependencies = self.filter_dependencies(
self.dependencies,
without_constraints,
names,
exclude_names,
groups,
)
# sort dependencies into their groups and add them at once in order
# to avoid version solver error in case dependencies depend on each
# other
dependency_groups = defaultdict(list)
for dependency in dependencies:
if isinstance(dependency.version, str):
dependency_groups[dependency.group].append(
f"{dependency.name}@latest"
)
if (
isinstance(dependency.version, dict)
and "version" in dependency.version
):
# skip dependencies with restriction markers
if {"markers", "python"} & set(dependency.version):
continue
extras = ",".join(dependency.version.get("extras", []))
suffix = f"[{extras}]" if extras else ""
package_version = f"{dependency.name}{suffix}@latest"
dependency_groups[dependency.group].append(package_version)
for group, packages in dependency_groups.items():
self.__run_poetry_add(
packages=packages,
group=group,
)
else:
logging.info("Running poetry update command")
self.__run_poetry_update()
# bump versions in pyproject
bumped_dependencies = self.filter_dependencies(
self.bumped_dependencies,
without_constraints,
names,
exclude_names,
groups,
)
table = self.pyproject["tool"]["poetry"]
for dependency in bumped_dependencies:
if dependency.group == "default":
table["dependencies"][dependency.name] = dependency.version
elif (
dependency.group == "dev"
and table.get("dev-dependencies", {}).get(dependency.name)
is not None
):
table["dev-dependencies"][dependency.name] = dependency.version
elif (
table.get("group", {})
.get(dependency.group, {})
.get("dependencies", {})
.get(dependency.name)
is not None
):
table["group"][dependency.group]["dependencies"][
dependency.name
] = dependency.version
else:
logging.warning(f"Couldn't bump dependency '{dependency.name}'")
@staticmethod
def __get_poetry_version() -> str:
"""Return the installed poetry version
Returns:
The poetry version installed
"""
output = cmd_run(["poetry", "--version"], capture_output=True)
# output is: 'Poetry version x.y.z'
return output.rsplit(" ", 1).pop().strip().replace(")", "")
@staticmethod
def __run_poetry_show() -> str:
"""Run poetry show command
Returns:
The output from the poetry show command
"""
return cmd_run(["poetry", "show", "--tree"], capture_output=True)
@staticmethod
def __run_poetry_update() -> None:
"""Run poetry update command"""
cmd_run(["poetry", "update"])
def __run_poetry_add(
self,
packages: List[str],
group: Optional[str],
) -> None:
"""Run poetry add command
Args:
package: The package(s) to add
group: The group the package(s) should be added to
"""
if group is None or group == "default":
cmd_run(["poetry", "add", *packages])
elif group == "dev" and self.poetry_version < version_.parse("1.2.0"):
cmd_run(["poetry", "add", *packages, f"--{group}"])
elif self.poetry_version >= version_.parse("1.2.0"):
cmd_run(["poetry", "add", *packages, "--group", group])
else:
logging.warning(f"Couldn't add package(s) '{packages}'")