pyproject-api 1.11.6__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.6 → pyproject_api-1.12.0}/.github/workflows/release.yaml +2 -2
  2. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/PKG-INFO +1 -1
  3. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_backend.py +25 -8
  4. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_frontend.py +32 -7
  5. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_version.py +2 -2
  6. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_backend.py +60 -0
  7. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_frontend.py +120 -7
  8. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.github/FUNDING.yaml +0 -0
  9. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.github/SECURITY.md +0 -0
  10. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.github/dependabot.yaml +0 -0
  11. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.github/release.yaml +0 -0
  12. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.github/workflows/check.yaml +0 -0
  13. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.gitignore +0 -0
  14. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.pre-commit-config.yaml +0 -0
  15. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/.readthedocs.yml +0 -0
  16. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/CODE_OF_CONDUCT.md +0 -0
  17. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/LICENSE +0 -0
  18. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/README.md +0 -0
  19. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/docs/api.rst +0 -0
  20. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/docs/conf.py +0 -0
  21. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/docs/index.rst +0 -0
  22. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/pyproject.toml +0 -0
  23. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/__init__.py +0 -0
  24. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/__main__.py +0 -0
  25. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_backend.pyi +0 -0
  26. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_util.py +0 -0
  27. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/_via_fresh_subprocess.py +0 -0
  28. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/src/pyproject_api/py.typed +0 -0
  29. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/_build_sdist.py +0 -0
  30. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/demo_pkg_inline/build.py +0 -0
  31. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/demo_pkg_inline/pyproject.toml +0 -0
  32. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_frontend_setuptools.py +0 -0
  33. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_main.py +0 -0
  34. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_util.py +0 -0
  35. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tests/test_version.py +0 -0
  36. {pyproject_api-1.11.6 → pyproject_api-1.12.0}/tox.toml +0 -0
@@ -27,7 +27,7 @@ jobs:
27
27
  - name: Build package
28
28
  run: uv build --python 3.15 --python-preference only-managed --sdist --wheel . --out-dir dist
29
29
  - name: Store the distribution packages
30
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
30
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
31
31
  with:
32
32
  name: ${{ env.dists-artifact-name }}
33
33
  path: dist/*
@@ -43,7 +43,7 @@ jobs:
43
43
  id-token: write
44
44
  steps:
45
45
  - name: Download all the dists
46
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
46
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
47
47
  with:
48
48
  name: ${{ env.dists-artifact-name }}
49
49
  path: dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyproject-api
3
- Version: 1.11.6
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
@@ -23,7 +23,8 @@ class BackendProxy:
23
23
  self.backend_object = backend_obj
24
24
  backend = importlib.import_module(self.backend_module)
25
25
  if self.backend_object:
26
- backend = getattr(backend, self.backend_object)
26
+ for name in self.backend_object.split("."):
27
+ backend = getattr(backend, name)
27
28
  self.backend = backend
28
29
 
29
30
  def __call__(self, name, *args, **kwargs):
@@ -87,23 +88,39 @@ def run(argv): # ruff:ignore[complex-structure, too-many-branches, too-many-sta
87
88
  flush()
88
89
  else:
89
90
  result = {}
91
+ response = None
90
92
  try:
91
93
  cmd = parsed_message["cmd"]
92
94
  print("Backend: run command {} with args {}".format(cmd, parsed_message["kwargs"]))
93
95
  result["return"] = backend_proxy(cmd, **parsed_message["kwargs"])
94
- if cmd == "_exit":
95
- break
96
+ response = json.dumps(result)
96
97
  except BaseException as exception: # ruff:ignore[blind-except]
97
- result["code"] = exception.code if isinstance(exception, SystemExit) else 1
98
- result["exc_type"] = exception.__class__.__name__
99
- 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)
100
112
  if not isinstance(exception, MissingCommand): # for missing command do not print stack
101
113
  traceback.print_exc()
114
+ else:
115
+ if cmd == "_exit":
116
+ break
102
117
  finally:
103
118
  try:
104
119
  encoding = locale.getpreferredencoding(do_setlocale=False)
105
- with open(result_file, "w", encoding=encoding) as file_handler: # ruff:ignore[builtin-open]
106
- 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)
107
124
  except Exception: # ruff:ignore[blind-except]
108
125
  traceback.print_exc()
109
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.6'
22
- __version_tuple__ = version_tuple = (1, 11, 6)
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"
@@ -232,19 +266,31 @@ def test_create_args_from_folder_rejects_bad_build_system_type(tmp_path: Path, t
232
266
  SubprocessFrontend.create_args_from_folder(tmp_path)
233
267
 
234
268
 
235
- def test_backend_obj(tmp_path: Path) -> None:
236
- toml = """
269
+ @pytest.mark.parametrize(
270
+ "backend_obj",
271
+ [
272
+ pytest.param("backend", id="attribute"),
273
+ pytest.param("backend:", id="trailing-colon"),
274
+ pytest.param("holder.backend", id="nested"),
275
+ pytest.param("holder.nested.backend", id="deeply-nested"),
276
+ ],
277
+ )
278
+ def test_backend_obj(tmp_path: Path, backend_obj: str) -> None:
279
+ toml = f"""
237
280
  [build-system]
238
281
  requires=[]
239
- build-backend = "build.api:backend:"
282
+ build-backend = "build.api:{backend_obj}"
240
283
  backend-path=["."]
241
284
  """
242
285
  api = """
286
+ from types import SimpleNamespace
287
+
243
288
  class A:
244
289
  def get_requires_for_build_sdist(self, config_settings=None):
245
290
  return ["a"]
246
291
 
247
292
  backend = A()
293
+ holder = SimpleNamespace(backend=backend, nested=SimpleNamespace(backend=backend))
248
294
  """
249
295
  (tmp_path / "pyproject.toml").write_text(dedent(toml))
250
296
  build = tmp_path / "build"
@@ -253,9 +299,7 @@ def test_backend_obj(tmp_path: Path) -> None:
253
299
  (build / "api.py").write_text(dedent(api))
254
300
  frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1])
255
301
  result = frontend.get_requires_for_build_sdist()
256
- for left, right in zip(result.requires, (Requirement("a"),), strict=False):
257
- assert isinstance(left, Requirement)
258
- assert str(left) == str(right)
302
+ assert result.requires == (Requirement("a"),)
259
303
 
260
304
 
261
305
  @pytest.mark.parametrize("of_type", ["wheel", "sdist"])
@@ -305,6 +349,35 @@ def test_bad_return_type_build_wheel(local_builder: Callable[[str], Path]) -> No
305
349
  assert exc.exc_type == "TypeError"
306
350
 
307
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
+
308
381
  def test_bad_return_type_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None:
309
382
  tmp_path = local_builder("def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): return 1")
310
383
  frontend = SubprocessFrontend(*SubprocessFrontend.create_args_from_folder(tmp_path)[:-1])
@@ -327,6 +400,46 @@ def test_prepare_metadata_for_build_wheel_meta_is_root(local_builder: Callable[[
327
400
  assert str(context.value) == f"the project root and the metadata directory can't be the same {tmp_path}"
328
401
 
329
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
+
330
443
  def test_no_wheel_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None:
331
444
  txt = "def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return 'out'"
332
445
  tmp_path = local_builder(txt)
File without changes
File without changes
File without changes