pyproject-api 1.11.7__tar.gz → 1.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/PKG-INFO +1 -1
  2. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_backend.py +23 -7
  3. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_frontend.py +32 -7
  4. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_version.py +2 -2
  5. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_backend.py +60 -0
  6. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_frontend.py +104 -1
  7. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/FUNDING.yaml +0 -0
  8. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/SECURITY.md +0 -0
  9. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/dependabot.yaml +0 -0
  10. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/release.yaml +0 -0
  11. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/workflows/check.yaml +0 -0
  12. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.github/workflows/release.yaml +0 -0
  13. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.gitignore +0 -0
  14. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.pre-commit-config.yaml +0 -0
  15. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/.readthedocs.yml +0 -0
  16. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/CODE_OF_CONDUCT.md +0 -0
  17. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/LICENSE +0 -0
  18. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/README.md +0 -0
  19. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/docs/api.rst +0 -0
  20. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/docs/conf.py +0 -0
  21. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/docs/index.rst +0 -0
  22. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/pyproject.toml +0 -0
  23. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/__init__.py +0 -0
  24. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/__main__.py +0 -0
  25. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_backend.pyi +0 -0
  26. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_util.py +0 -0
  27. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/_via_fresh_subprocess.py +0 -0
  28. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/src/pyproject_api/py.typed +0 -0
  29. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/_build_sdist.py +0 -0
  30. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/demo_pkg_inline/build.py +0 -0
  31. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/demo_pkg_inline/pyproject.toml +0 -0
  32. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_frontend_setuptools.py +0 -0
  33. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_main.py +0 -0
  34. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_util.py +0 -0
  35. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tests/test_version.py +0 -0
  36. {pyproject_api-1.11.7 → pyproject_api-1.12.0}/tox.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyproject-api
3
- Version: 1.11.7
3
+ Version: 1.12.0
4
4
  Summary: API to interact with the python pyproject.toml based projects
5
5
  Project-URL: Changelog, https://github.com/tox-dev/pyproject-api/releases
6
6
  Project-URL: Homepage, https://pyproject-api.readthedocs.io
@@ -88,23 +88,39 @@ def run(argv): # ruff:ignore[complex-structure, too-many-branches, too-many-sta
88
88
  flush()
89
89
  else:
90
90
  result = {}
91
+ response = None
91
92
  try:
92
93
  cmd = parsed_message["cmd"]
93
94
  print("Backend: run command {} with args {}".format(cmd, parsed_message["kwargs"]))
94
95
  result["return"] = backend_proxy(cmd, **parsed_message["kwargs"])
95
- if cmd == "_exit":
96
- break
96
+ response = json.dumps(result)
97
97
  except BaseException as exception: # ruff:ignore[blind-except]
98
- result["code"] = exception.code if isinstance(exception, SystemExit) else 1
99
- result["exc_type"] = exception.__class__.__name__
100
- result["exc_msg"] = str(exception)
98
+ try:
99
+ result = {
100
+ "code": exception.code if isinstance(exception, SystemExit) else 1,
101
+ "exc_type": exception.__class__.__name__,
102
+ "exc_msg": str(exception),
103
+ }
104
+ response = json.dumps(result)
105
+ except BaseException: # ruff:ignore[blind-except]
106
+ result = {
107
+ "code": 1,
108
+ "exc_type": "RuntimeError",
109
+ "exc_msg": "Failed to serialize backend exception",
110
+ }
111
+ response = json.dumps(result)
101
112
  if not isinstance(exception, MissingCommand): # for missing command do not print stack
102
113
  traceback.print_exc()
114
+ else:
115
+ if cmd == "_exit":
116
+ break
103
117
  finally:
104
118
  try:
105
119
  encoding = locale.getpreferredencoding(do_setlocale=False)
106
- with open(result_file, "w", encoding=encoding) as file_handler: # ruff:ignore[builtin-open]
107
- json.dump(result, file_handler)
120
+ with open( # ruff:ignore[builtin-open, write-whole-file] # Python 2.7 compatible
121
+ result_file, "w", encoding=encoding
122
+ ) as file_handler:
123
+ file_handler.write(response)
108
124
  except Exception: # ruff:ignore[blind-except]
109
125
  traceback.print_exc()
110
126
  finally:
@@ -9,7 +9,7 @@ from contextlib import contextmanager
9
9
  from pathlib import Path
10
10
  from tempfile import NamedTemporaryFile, TemporaryDirectory
11
11
  from time import sleep
12
- from typing import TYPE_CHECKING, Any, Literal, NamedTuple, NoReturn, TypedDict, cast
12
+ from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple, NoReturn, TypedDict, cast
13
13
  from zipfile import ZipFile
14
14
 
15
15
  from packaging.requirements import Requirement
@@ -211,13 +211,16 @@ class Frontend(ABC):
211
211
 
212
212
  :param folder: the python project folder
213
213
  :return: the frontend creation args
214
- :raises ValueError: if ``build-system`` is not a table, or one of its keys has a type PEP-518 rules out
214
+ :raises ValueError: if ``build-system`` is not a table, one of its keys has a type PEP-518 rules out, or a
215
+ ``backend-path`` entry does not resolve inside the project (PEP-517 in-tree backends)
215
216
 
216
217
  E.g., to create a frontend from a python project folder:
217
218
 
218
219
  .. code:: python
219
220
 
220
- frontend = Frontend(*Frontend.create_args_from_folder(project_folder))
221
+ from pyproject_api import SubprocessFrontend
222
+
223
+ frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(project_folder)[:-1])
221
224
  """
222
225
  folder = folder.absolute()
223
226
  py_project_toml = folder / "pyproject.toml"
@@ -228,7 +231,9 @@ class Frontend(ABC):
228
231
  msg = f"build-system must be a table, got {build_system!r}"
229
232
  raise ValueError(msg)
230
233
  if "backend-path" in build_system:
231
- backend_paths: tuple[Path, ...] = tuple(folder / p for p in _str_list(build_system, "backend-path"))
234
+ backend_paths: tuple[Path, ...] = tuple(
235
+ _in_tree_path(folder, entry) for entry in _str_list(build_system, "backend-path")
236
+ )
232
237
  else:
233
238
  backend_paths = ()
234
239
  if "requires" in build_system:
@@ -343,9 +348,16 @@ class Frontend(ABC):
343
348
  return MetadataForBuildWheelResult(metadata_directory / basename, out, err)
344
349
 
345
350
  def _check_metadata_dir(self, metadata_directory: Path) -> None:
346
- if metadata_directory.resolve() == self._root.resolve():
347
- msg = f"the project root and the metadata directory can't be the same {self._root}"
348
- raise RuntimeError(msg)
351
+ metadata_path: Final[Path] = metadata_directory.resolve()
352
+ project_root: Final[Path] = self._root.resolve()
353
+ if metadata_path == project_root:
354
+ same_root_message: Final[str] = (
355
+ f"the project root and the metadata directory can't be the same {self._root}"
356
+ )
357
+ raise RuntimeError(same_root_message)
358
+ if metadata_path in project_root.parents:
359
+ ancestor_message: Final[str] = f"the metadata directory can't contain the project root {self._root}"
360
+ raise RuntimeError(ancestor_message)
349
361
  if metadata_directory.exists(): # start with fresh
350
362
  ensure_empty_dir(metadata_directory)
351
363
  metadata_directory.mkdir(parents=True, exist_ok=True)
@@ -539,6 +551,19 @@ def _str_list(build_system: dict[str, Any], key: str) -> list[str]:
539
551
  return value
540
552
 
541
553
 
554
+ def _in_tree_path(folder: Path, entry: str) -> Path:
555
+ path = folder / entry
556
+ # Python 3.15 on Windows resolves a path containing NUL instead of raising
557
+ try:
558
+ inside = "\0" not in entry and not Path(entry).is_absolute() and path.resolve().is_relative_to(folder.resolve())
559
+ except (OSError, RuntimeError): # RuntimeError: symlink loop before Python 3.13
560
+ inside = False
561
+ if not inside:
562
+ msg = f"build-system.backend-path entry {entry!r} must be a relative path inside the project {folder}"
563
+ raise ValueError(msg)
564
+ return path
565
+
566
+
542
567
  __all__ = [
543
568
  "BackendFailed",
544
569
  "CmdStatus",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.11.7'
22
- __version_tuple__ = version_tuple = (1, 11, 7)
21
+ __version__ = version = '1.12.0'
22
+ __version_tuple__ = version_tuple = (1, 12, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,10 +2,14 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import subprocess # ruff:ignore[suspicious-subprocess-import]
6
+ import sys
7
+ from textwrap import dedent
5
8
  from typing import TYPE_CHECKING, Any
6
9
 
7
10
  import pytest
8
11
 
12
+ from pyproject_api import _backend
9
13
  from pyproject_api._backend import BackendProxy, read_line, run
10
14
 
11
15
  if TYPE_CHECKING:
@@ -130,6 +134,62 @@ def test_reuse_process(mocker: pytest_mock.MockerFixture, capsys: pytest.Capture
130
134
  assert "SystemExit: 2" in captured.err
131
135
 
132
136
 
137
+ @pytest.mark.parametrize("code", ["Path('failure.txt')", "b'failure'"])
138
+ @pytest.mark.parametrize("previous_success", [False, True])
139
+ def test_reused_process_non_serializable_exit(tmp_path: Path, code: str, previous_success: bool) -> None:
140
+ (tmp_path / "exit_backend.py").write_text(
141
+ dedent(f"""
142
+ import os
143
+ from pathlib import Path
144
+
145
+ calls = 0
146
+
147
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
148
+ global calls
149
+ calls += 1
150
+ print("hook pid={{}} calls={{}}".format(os.getpid(), calls))
151
+ if config_settings["fail"]:
152
+ raise SystemExit({code})
153
+ return "demo-{{}}.whl".format(calls)
154
+ """)
155
+ )
156
+ failures = [False, True, False] if previous_success else [True, False]
157
+ requests = [
158
+ {"cmd": "build_wheel", "kwargs": {"wheel_directory": str(tmp_path), "config_settings": {"fail": fail}}}
159
+ for fail in failures
160
+ ]
161
+ requests.append({"cmd": "_exit", "kwargs": {}})
162
+ result_files = [tmp_path / f"response-{index}.json" for index in range(len(requests))]
163
+ messages = [
164
+ json.dumps({**request, "result": str(path)}) for request, path in zip(requests, result_files, strict=True)
165
+ ]
166
+ completed = subprocess.run(
167
+ [sys.executable, _backend.__file__, "True", "exit_backend"],
168
+ input="\n".join(messages) + "\n",
169
+ capture_output=True,
170
+ text=True,
171
+ cwd=tmp_path,
172
+ env={**os.environ, "PYTHONPATH": str(tmp_path)},
173
+ timeout=30,
174
+ check=False,
175
+ )
176
+
177
+ # Inspect the failed request first: the previous candidate returns the earlier wheel here.
178
+ failed = json.loads(result_files[failures.index(True)].read_text())
179
+ assert "return" not in failed
180
+ assert failed == {"code": 1, "exc_type": "RuntimeError", "exc_msg": "Failed to serialize backend exception"}
181
+ assert completed.returncode == 0, completed.stderr
182
+ for index, fail in enumerate(failures):
183
+ if not fail:
184
+ assert json.loads(result_files[index].read_text()) == {"return": f"demo-{index + 1}.whl"}
185
+ assert json.loads(result_files[-1].read_text()) == {"return": 0}
186
+ hook_lines = [line for line in completed.stdout.splitlines() if line.startswith("hook pid=")]
187
+ assert len(hook_lines) == len(failures)
188
+ assert len({line.split()[1] for line in hook_lines}) == 1
189
+ assert [line.split()[2] for line in hook_lines] == [f"calls={index + 1}" for index in range(len(failures))]
190
+ assert "SystemExit" in completed.stderr
191
+
192
+
133
193
  def test_read_line_success() -> None:
134
194
  r, w = os.pipe()
135
195
  try:
@@ -5,7 +5,7 @@ import subprocess # ruff:ignore[suspicious-subprocess-import]
5
5
  import sys
6
6
  from pathlib import Path
7
7
  from textwrap import dedent
8
- from typing import TYPE_CHECKING, Literal
8
+ from typing import TYPE_CHECKING, Final, Literal
9
9
 
10
10
  import pytest
11
11
  from packaging.requirements import Requirement
@@ -16,6 +16,8 @@ from pyproject_api._via_fresh_subprocess import SubprocessFrontend
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Callable
18
18
 
19
+ import pytest_mock
20
+
19
21
 
20
22
  @pytest.fixture
21
23
  def local_builder(tmp_path: Path) -> Callable[[str], Path]:
@@ -183,6 +185,38 @@ def test_empty_pyproject(tmp_path: Path) -> None:
183
185
  assert str(left) == str(right)
184
186
 
185
187
 
188
+ @pytest.mark.parametrize(
189
+ "backend_path",
190
+ [
191
+ pytest.param("../outside", id="parent"),
192
+ pytest.param("{tmp}/outside", id="absolute-outside"),
193
+ pytest.param("{tmp}/project", id="absolute-inside"),
194
+ pytest.param("link", id="symlink-outside"),
195
+ pytest.param("\\u0000", id="nul"),
196
+ ],
197
+ )
198
+ def test_create_args_from_folder_rejects_out_of_tree_backend_path(tmp_path: Path, backend_path: str) -> None:
199
+ project = tmp_path / "project"
200
+ project.mkdir()
201
+ (project / "link").symlink_to(tmp_path, target_is_directory=True)
202
+ (project / "pyproject.toml").write_text(
203
+ f'[build-system]\nbackend-path = ["{backend_path.format(tmp=tmp_path.as_posix())}"]\nbuild-backend = "b"\n'
204
+ )
205
+
206
+ with pytest.raises(ValueError, match=r"backend-path entry .* must be a relative path inside the project"):
207
+ SubprocessFrontend.create_args_from_folder(project)
208
+
209
+
210
+ def test_create_args_from_folder_rejects_unresolvable_backend_path(
211
+ tmp_path: Path, mocker: pytest_mock.MockerFixture
212
+ ) -> None:
213
+ (tmp_path / "pyproject.toml").write_text('[build-system]\nbackend-path = ["."]\nbuild-backend = "b"\n')
214
+ mocker.patch.object(Path, "resolve", autospec=True, side_effect=RuntimeError("Symlink loop"))
215
+
216
+ with pytest.raises(ValueError, match=r"backend-path entry '\.' must be a relative path inside the project"):
217
+ SubprocessFrontend.create_args_from_folder(tmp_path)
218
+
219
+
186
220
  @pytest.fixture(scope="session")
187
221
  def demo_pkg_inline() -> Path:
188
222
  return Path(__file__).absolute().parent / "demo_pkg_inline"
@@ -315,6 +349,35 @@ def test_bad_return_type_build_wheel(local_builder: Callable[[str], Path]) -> No
315
349
  assert exc.exc_type == "TypeError"
316
350
 
317
351
 
352
+ @pytest.mark.parametrize("value", ["Path('demo.whl')", "b'demo.whl'", "{'demo.whl'}"])
353
+ def test_non_serializable_hook_result(value: str, local_builder: Callable[[str], Path]) -> None:
354
+ root = local_builder(f"""
355
+ import sys
356
+ from pathlib import Path
357
+
358
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
359
+ print("hook stdout")
360
+ print("hook stderr", file=sys.stderr)
361
+ if config_settings is None:
362
+ return {value}
363
+ return "demo-1.0-py3-none-any.whl"
364
+ """)
365
+ frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(root)[:-1])
366
+
367
+ with pytest.raises(BackendFailed) as context:
368
+ frontend.build_wheel(root / "dist")
369
+
370
+ exc = context.value
371
+ assert exc.code == 1
372
+ assert exc.exc_type == "TypeError"
373
+ assert "not JSON serializable" in exc.exc_msg
374
+ assert "hook stdout" in exc.out
375
+ assert "hook stderr" in exc.err
376
+ assert "TypeError" in exc.err
377
+ result = frontend.build_wheel(root / "dist", config_settings={})
378
+ assert result.wheel.name == "demo-1.0-py3-none-any.whl"
379
+
380
+
318
381
  def test_bad_return_type_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None:
319
382
  tmp_path = local_builder("def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): return 1")
320
383
  frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1])
@@ -337,6 +400,46 @@ def test_prepare_metadata_for_build_wheel_meta_is_root(local_builder: Callable[[
337
400
  assert str(context.value) == f"the project root and the metadata directory can't be the same {tmp_path}"
338
401
 
339
402
 
403
+ @pytest.mark.parametrize("hook", ["prepare_metadata_for_build_wheel", "prepare_metadata_for_build_editable"])
404
+ @pytest.mark.parametrize("alias", [False, True], ids=["directory", "symlink"])
405
+ def test_prepare_metadata_rejects_ancestor_directory(tmp_path: Path, hook: str, alias: bool) -> None:
406
+ # The old cleanup can remove the whole ancestor, so keep it inside tmp_path.
407
+ ancestor: Final[Path] = tmp_path / "sandbox"
408
+ root: Final[Path] = ancestor / "project"
409
+ root.mkdir(parents=True)
410
+ source: Final[Path] = root / "build_tester.py"
411
+ source.write_text("source content\n")
412
+ unrelated: Final[Path] = ancestor / "unrelated.txt"
413
+ unrelated.write_text("keep me")
414
+ metadata_directory: Final[Path] = tmp_path / "alias" if alias else ancestor
415
+ if alias:
416
+ metadata_directory.symlink_to(ancestor, target_is_directory=True)
417
+ frontend: Final[SubprocessFrontend] = SubprocessFrontend(root, (root,), "build_tester", None, ())
418
+
419
+ with pytest.raises(RuntimeError, match="the metadata directory can't contain the project root"):
420
+ getattr(frontend, hook)(metadata_directory)
421
+
422
+ assert source.read_text() == "source content\n"
423
+ assert unrelated.read_text() == "keep me"
424
+
425
+
426
+ @pytest.mark.parametrize("hook", ["prepare_metadata_for_build_wheel", "prepare_metadata_for_build_editable"])
427
+ def test_prepare_metadata_allows_sibling_directory(tmp_path: Path, hook: str) -> None:
428
+ root: Final[Path] = tmp_path / "project"
429
+ root.mkdir()
430
+ source: Final[Path] = root / "build_tester.py"
431
+ source.write_text("")
432
+ metadata_directory: Final[Path] = tmp_path / "project-metadata"
433
+ metadata_directory.mkdir()
434
+ (metadata_directory / "stale.txt").write_text("remove me")
435
+ frontend: Final[SubprocessFrontend] = SubprocessFrontend(root, (root,), "build_tester", None, ())
436
+
437
+ assert getattr(frontend, hook)(metadata_directory) is None
438
+
439
+ assert source.is_file()
440
+ assert list(metadata_directory.iterdir()) == []
441
+
442
+
340
443
  def test_no_wheel_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None:
341
444
  txt = "def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return 'out'"
342
445
  tmp_path = local_builder(txt)
File without changes
File without changes
File without changes