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
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
|
from __future__ import annotations
import logging
import os
import sys
from collections import defaultdict
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from typing import Any, Dict, Generator, Iterator, NoReturn, Optional, Sequence, cast
from cachetools import cached
from packaging.requirements import Requirement
from pyproject_api import BackendFailed, CmdStatus, Frontend
from tox.config.sets import EnvConfigSet
from tox.execute.api import ExecuteStatus
from tox.execute.pep517_backend import LocalSubProcessPep517Executor
from tox.execute.request import StdinSource
from tox.plugin import impl
from tox.tox_env.api import ToxEnvCreateArgs
from tox.tox_env.errors import Fail
from tox.tox_env.package import Package, PackageToxEnv
from tox.tox_env.python.package import (
EditableLegacyPackage,
EditablePackage,
PythonPackageToxEnv,
SdistPackage,
WheelPackage,
)
from tox.tox_env.register import ToxEnvRegister
from tox.tox_env.runner import RunToxEnv
from tox.util.file_view import create_session_view
from ..api import VirtualEnv
from .util import dependencies_with_extras, dependencies_with_extras_from_markers
if sys.version_info >= (3, 8): # pragma: no cover (py38+)
from importlib.metadata import Distribution, PathDistribution
else: # pragma: no cover (<py38)
from importlib_metadata import Distribution, PathDistribution
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
import tomllib
else: # pragma: no cover (py311+)
import tomli as tomllib
ConfigSettings = Optional[Dict[str, Any]]
class ToxBackendFailed(Fail, BackendFailed):
def __init__(self, backend_failed: BackendFailed) -> None:
Fail.__init__(self)
result: dict[str, Any] = {
"code": backend_failed.code,
"exc_type": backend_failed.exc_type,
"exc_msg": backend_failed.exc_msg,
}
BackendFailed.__init__(
self,
result,
backend_failed.out,
backend_failed.err,
)
class BuildEditableNotSupported(RuntimeError):
"""raised when build editable is not supported"""
class ToxCmdStatus(CmdStatus):
def __init__(self, execute_status: ExecuteStatus) -> None:
self._execute_status = execute_status
@property
def done(self) -> bool:
# 1. process died
status = self._execute_status
if status.exit_code is not None: # pragma: no branch
return True # pragma: no cover
# 2. the backend output reported back that our command is done
return b"\n" in status.out.rpartition(b"Backend: Wrote response ")[0]
def out_err(self) -> tuple[str, str]:
status = self._execute_status
if status is None or status.outcome is None: # interrupt before status create # pragma: no branch
return "", "" # pragma: no cover
return status.outcome.out_err()
class Pep517VirtualEnvPackager(PythonPackageToxEnv, VirtualEnv):
"""local file system python virtual environment via the virtualenv package"""
def __init__(self, create_args: ToxEnvCreateArgs) -> None:
super().__init__(create_args)
self._frontend_: Pep517VirtualEnvFrontend | None = None
self.builds: defaultdict[str, list[EnvConfigSet]] = defaultdict(list)
self._distribution_meta: PathDistribution | None = None
self._package_dependencies: list[Requirement] | None = None
self._package_name: str | None = None
self._pkg_lock = RLock() # can build only one package at a time
self.root = self.conf["package_root"]
self._package_paths: set[Path] = set()
@staticmethod
def id() -> str:
return "virtualenv-pep-517"
@property
def _frontend(self) -> Pep517VirtualEnvFrontend:
if self._frontend_ is None:
self._frontend_ = Pep517VirtualEnvFrontend(self.root, self)
return self._frontend_
def register_config(self) -> None:
super().register_config()
self.conf.add_config(
keys=["meta_dir"],
of_type=Path,
default=lambda conf, name: self.env_dir / ".meta", # noqa: U100
desc="directory where to put the project metadata files",
)
self.conf.add_config(
keys=["pkg_dir"],
of_type=Path,
default=lambda conf, name: self.env_dir / "dist", # noqa: U100
desc="directory where to put project packages",
)
@property
def pkg_dir(self) -> Path:
return cast(Path, self.conf["pkg_dir"])
@property
def meta_folder(self) -> Path:
meta_folder: Path = self.conf["meta_dir"]
meta_folder.mkdir(exist_ok=True)
return meta_folder
@property
def meta_folder_if_populated(self) -> Path | None:
"""Return the metadata directory if it contains any files, otherwise None."""
meta_folder = self.meta_folder
if meta_folder.exists() and tuple(meta_folder.iterdir()):
return meta_folder
return None
def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]:
yield from super().register_run_env(run_env)
build_type = run_env.conf["package"]
self.builds[build_type].append(run_env.conf)
def _setup_env(self) -> None:
super()._setup_env()
if "editable" in self.builds:
if not self._frontend.optional_hooks["build_editable"]:
raise BuildEditableNotSupported
build_requires = self._frontend.get_requires_for_build_editable().requires
self._install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_editable")
if "wheel" in self.builds:
build_requires = self._frontend.get_requires_for_build_wheel().requires
self._install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_wheel")
if "sdist" in self.builds or "external" in self.builds:
build_requires = self._frontend.get_requires_for_build_sdist().requires
self._install(build_requires, PythonPackageToxEnv.__name__, "requires_for_build_sdist")
def _teardown(self) -> None:
executor = self._frontend.backend_executor
if executor is not None: # pragma: no branch
try:
if executor.is_alive:
self._frontend._send("_exit") # try first on amicable shutdown
except SystemExit: # pragma: no cover # if already has been interrupted ignore
pass
finally:
executor.close()
for path in self._package_paths:
if path.exists():
logging.debug("delete package %s", path)
path.unlink()
super()._teardown()
def perform_packaging(self, for_env: EnvConfigSet) -> list[Package]:
"""build the package to install"""
try:
deps = self._load_deps(for_env)
except BuildEditableNotSupported:
targets = [e for e in self.builds.pop("editable") if e["package"] == "editable"]
names = ", ".join(sorted({t.env_name for t in targets if t.env_name}))
logging.error(
f"package config for {names} is editable, however the build backend {self._frontend.backend}"
f" does not support PEP-660, falling back to editable-legacy - change your configuration to it",
)
for env in targets:
env._defined["package"].value = "editable-legacy" # type: ignore
self.builds["editable-legacy"].append(env)
deps = self._load_deps(for_env)
of_type: str = for_env["package"]
if of_type == "editable-legacy":
self.setup()
deps = [*self.requires(), *self._frontend.get_requires_for_build_sdist().requires] + deps
package: Package = EditableLegacyPackage(self.core["tox_root"], deps) # the folder itself is the package
elif of_type == "sdist":
self.setup()
with self._pkg_lock:
sdist = self._frontend.build_sdist(sdist_directory=self.pkg_dir).sdist
sdist = create_session_view(sdist, self._package_temp_path)
self._package_paths.add(sdist)
package = SdistPackage(sdist, deps)
elif of_type in {"wheel", "editable"}:
w_env = self._wheel_build_envs.get(for_env["wheel_build_env"])
if w_env is not None and w_env is not self:
with w_env.display_context(self._has_display_suspended):
return w_env.perform_packaging(for_env)
else:
self.setup()
method = "build_editable" if of_type == "editable" else "build_wheel"
with self._pkg_lock:
wheel = getattr(self._frontend, method)(
wheel_directory=self.pkg_dir,
metadata_directory=self.meta_folder_if_populated,
config_settings=self._wheel_config_settings,
).wheel
wheel = create_session_view(wheel, self._package_temp_path)
self._package_paths.add(wheel)
package = (EditablePackage if of_type == "editable" else WheelPackage)(wheel, deps)
else: # pragma: no cover # for when we introduce new packaging types and don't implement
raise TypeError(f"cannot handle package type {of_type}") # pragma: no cover
return [package]
@property
def _package_temp_path(self) -> Path:
return cast(Path, self.core["temp_dir"]) / "package"
def _load_deps(self, for_env: EnvConfigSet) -> list[Requirement]:
# first check if this is statically available via PEP-621
deps = self._load_deps_from_static(for_env)
if deps is None:
deps = self._load_deps_from_built_metadata(for_env)
return deps
def _load_deps_from_static(self, for_env: EnvConfigSet) -> list[Requirement] | None:
pyproject_file = self.core["package_root"] / "pyproject.toml"
if not pyproject_file.exists(): # check if it's static PEP-621 metadata
return None
with pyproject_file.open("rb") as file_handler:
pyproject = tomllib.load(file_handler)
if "project" not in pyproject:
return None # is not a PEP-621 pyproject
project = pyproject["project"]
extras: set[str] = for_env["extras"]
for dynamic in project.get("dynamic", []):
if dynamic == "dependencies" or (extras and dynamic == "optional-dependencies"):
return None # if any dependencies are dynamic we can just calculate all dynamically
deps_with_markers: list[tuple[Requirement, set[str | None]]] = [
(Requirement(i), {None}) for i in project.get("dependencies", [])
]
optional_deps = project.get("optional-dependencies", {})
for extra, reqs in optional_deps.items():
deps_with_markers.extend((Requirement(req), {extra}) for req in (reqs or []))
return dependencies_with_extras_from_markers(
deps_with_markers=deps_with_markers,
extras=extras,
package_name=project.get("name", "."),
)
def _load_deps_from_built_metadata(self, for_env: EnvConfigSet) -> list[Requirement]:
# dependencies might depend on the python environment we're running in => if we build a wheel use that env
# to calculate the package metadata, otherwise ourselves
of_type: str = for_env["package"]
reqs: list[Requirement] | None = None
name = ""
if of_type in ("wheel", "editable"): # wheel packages
w_env = self._wheel_build_envs.get(for_env["wheel_build_env"])
if w_env is not None and w_env is not self:
with w_env.display_context(self._has_display_suspended):
if isinstance(w_env, Pep517VirtualEnvPackager):
reqs, name = w_env.get_package_dependencies(for_env), w_env.get_package_name(for_env)
else:
reqs = []
if reqs is None:
reqs = self.get_package_dependencies(for_env)
name = self.get_package_name(for_env)
extras: set[str] = for_env["extras"]
deps = dependencies_with_extras(reqs, extras, name)
return deps
def get_package_dependencies(self, for_env: EnvConfigSet) -> list[Requirement]:
with self._pkg_lock:
if self._package_dependencies is None: # pragma: no branch
self._ensure_meta_present(for_env)
requires: list[str] = cast(PathDistribution, self._distribution_meta).requires or []
self._package_dependencies = [Requirement(i) for i in requires] # pragma: no branch
return self._package_dependencies
def get_package_name(self, for_env: EnvConfigSet) -> str:
with self._pkg_lock:
if self._package_name is None: # pragma: no branch
self._ensure_meta_present(for_env)
self._package_name = cast(PathDistribution, self._distribution_meta).metadata["Name"]
return self._package_name
def _ensure_meta_present(self, for_env: EnvConfigSet) -> None:
if self._distribution_meta is not None: # pragma: no branch
return # pragma: no cover
self.setup()
end = self._frontend
if for_env["package"] == "editable":
dist_info = end.prepare_metadata_for_build_editable(self.meta_folder, self._wheel_config_settings).metadata
else:
dist_info = end.prepare_metadata_for_build_wheel(self.meta_folder, self._wheel_config_settings).metadata
self._distribution_meta = Distribution.at(str(dist_info))
@property
def _wheel_config_settings(self) -> ConfigSettings | None:
return {"--build-option": []}
def requires(self) -> tuple[Requirement, ...]:
return self._frontend.requires
class Pep517VirtualEnvFrontend(Frontend):
def __init__(self, root: Path, env: Pep517VirtualEnvPackager) -> None:
super().__init__(*Frontend.create_args_from_folder(root))
self._tox_env = env
self._backend_executor_: LocalSubProcessPep517Executor | None = None
into: dict[str, Any] = {}
pkg_cache = cached(
into,
key=lambda *args, **kwargs: "wheel" if "wheel_directory" in kwargs else "sdist", # noqa: U100
)
self.build_wheel = pkg_cache(self.build_wheel) # type: ignore
self.build_sdist = pkg_cache(self.build_sdist) # type: ignore
self.build_editable = pkg_cache(self.build_editable) # type: ignore
@property
def backend_cmd(self) -> Sequence[str]:
return ["python"] + self.backend_args
def _send(self, cmd: str, **kwargs: Any) -> tuple[Any, str, str]:
try:
if cmd in ("prepare_metadata_for_build_wheel", "prepare_metadata_for_build_editable"):
# given we'll build a wheel we might skip the prepare step
if "wheel" in self._tox_env.builds or "editable" in self._tox_env.builds:
return None, "", "" # will need to build wheel either way, avoid prepare
return super()._send(cmd, **kwargs)
except BackendFailed as exception:
raise exception if isinstance(exception, ToxBackendFailed) else ToxBackendFailed(exception) from exception
@contextmanager
def _send_msg(
self,
cmd: str,
result_file: Path, # noqa: U100
msg: str,
) -> Iterator[ToxCmdStatus]:
with self._tox_env.execute_async(
cmd=self.backend_cmd,
cwd=self._root,
stdin=StdinSource.API,
show=None,
run_id=cmd,
executor=self.backend_executor,
) as execute_status:
execute_status.write_stdin(f"{msg}{os.linesep}")
yield ToxCmdStatus(execute_status)
outcome = execute_status.outcome
if outcome is not None: # pragma: no branch
outcome.assert_success()
def _unexpected_response(self, cmd: str, got: Any, expected_type: Any, out: str, err: str) -> NoReturn:
try:
super()._unexpected_response(cmd, got, expected_type, out, err)
except BackendFailed as exception:
raise exception if isinstance(exception, ToxBackendFailed) else ToxBackendFailed(exception) from exception
@property
def backend_executor(self) -> LocalSubProcessPep517Executor:
if self._backend_executor_ is None:
environment_variables = self._tox_env.environment_variables.copy()
backend = os.pathsep.join(str(i) for i in self._backend_paths).strip()
if backend:
environment_variables["PYTHONPATH"] = backend
self._backend_executor_ = LocalSubProcessPep517Executor(
colored=self._tox_env.options.is_colored,
cmd=self.backend_cmd,
env=environment_variables,
cwd=self._root,
)
return self._backend_executor_
@contextmanager
def _wheel_directory(self) -> Iterator[Path]:
yield self._tox_env.pkg_dir # use our local wheel directory for building wheel
@impl
def tox_register_tox_env(register: ToxEnvRegister) -> None:
register.add_package_env(Pep517VirtualEnvPackager)
|