pyproject-api 1.12.4__tar.gz → 1.12.5__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 (48) hide show
  1. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/check.yaml +19 -11
  2. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/prepare-release.yaml +9 -2
  3. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/CONTRIBUTING.md +4 -0
  4. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/PKG-INFO +1 -1
  5. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/api.rst +8 -0
  6. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/changelog.rst +16 -0
  7. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_frontend.py +2 -5
  8. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_util.py +5 -2
  9. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_version.py +2 -2
  10. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_frontend.py +27 -0
  11. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_frontend_setuptools.py +1 -2
  12. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_util.py +41 -0
  13. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/FUNDING.yaml +0 -0
  14. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/SECURITY.md +0 -0
  15. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/THREAT_MODEL.md +0 -0
  16. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/dependabot.yaml +0 -0
  17. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/release.yaml +0 -0
  18. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/codeql.yaml +0 -0
  19. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/news.yaml +0 -0
  20. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/release.yaml +0 -0
  21. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/scorecard.yaml +0 -0
  22. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.github/workflows/upgrade-release-tools.yaml +0 -0
  23. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.gitignore +0 -0
  24. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.pre-commit-config.yaml +0 -0
  25. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/.readthedocs.yml +0 -0
  26. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/CODE_OF_CONDUCT.md +0 -0
  27. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/LICENSE +0 -0
  28. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/README.md +0 -0
  29. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/changelog.d/.gitkeep +0 -0
  30. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/changelog.d/template.jinja2 +0 -0
  31. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/conf.py +0 -0
  32. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/contributing.rst +0 -0
  33. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/docs/index.rst +0 -0
  34. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/pylock.toml +0 -0
  35. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/pyproject.toml +0 -0
  36. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/__init__.py +0 -0
  37. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/__main__.py +0 -0
  38. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_backend.py +0 -0
  39. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_backend.pyi +0 -0
  40. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/_via_fresh_subprocess.py +0 -0
  41. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/src/pyproject_api/py.typed +0 -0
  42. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/_build_sdist.py +0 -0
  43. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/demo_pkg_inline/build.py +0 -0
  44. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/demo_pkg_inline/pyproject.toml +0 -0
  45. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_backend.py +0 -0
  46. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_main.py +0 -0
  47. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tests/test_version.py +0 -0
  48. {pyproject_api-1.12.4 → pyproject_api-1.12.5}/tox.toml +0 -0
@@ -7,16 +7,16 @@ on:
7
7
  pull_request:
8
8
  types: [opened, synchronize, reopened]
9
9
  schedule:
10
- - cron: "0 8 * * *"
10
+ - cron: "43 7 * * *"
11
11
  concurrency:
12
12
  group: check-${{ github.ref }}
13
- cancel-in-progress: true
13
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
14
14
  permissions:
15
15
  contents: read
16
16
  jobs:
17
17
  test:
18
18
  name: 🧪 Test
19
- uses: tox-dev/github-workflows/.github/workflows/check.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0
19
+ uses: tox-dev/github-workflows/.github/workflows/check.yaml@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5
20
20
  permissions:
21
21
  contents: read
22
22
  with:
@@ -40,20 +40,28 @@ jobs:
40
40
  {"env": "type", "os": "windows-2025-vs2026"},
41
41
  {"env": "dev", "os": "windows-2025-vs2026"},
42
42
  {"env": "pkg_meta", "os": "windows-2025-vs2026"},
43
- {"env": "3.15", "os": "macos-26"},
44
- {"env": "3.15t", "python": "3.15t", "os": "macos-26"},
45
- {"env": "3.14", "python": "3.14", "os": "macos-26"},
46
- {"env": "3.13", "python": "3.13", "os": "macos-26"},
47
- {"env": "3.12", "python": "3.12", "os": "macos-26"},
48
- {"env": "3.11", "python": "3.11", "os": "macos-26"}
43
+ {"env": "3.15", "os": "macos-26"}
49
44
  ]
45
+ release:
46
+ name: 🏷️ Release dry run
47
+ uses: $/.github/workflows/prepare-release.yaml
48
+ permissions:
49
+ contents: read
50
+ pull-requests: read
51
+ with:
52
+ dry-run: true
53
+ hooks:
54
+ name: 🧹 Hooks under the cooldown
55
+ uses: tox-dev/github-workflows/.github/workflows/cooled-hooks.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2
56
+ permissions:
57
+ contents: read
50
58
  gate:
51
59
  name: 🚦 ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Non-PR checks pass' }}
52
60
  if: ${{ !cancelled() }} # codespell:ignore cancelled
53
- needs: [test]
61
+ needs: [test, release, hooks]
54
62
  runs-on: ubuntu-26.04
55
63
  steps:
56
64
  - name: 🚦 Require all needed jobs to pass
57
- uses: tox-dev/github-workflows/required-checks@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0
65
+ uses: tox-dev/github-workflows/required-checks@13381289334b15375958be7f7438f3c3d860e575 # 6.5.5
58
66
  with:
59
67
  needs: ${{ toJSON(needs) }}
@@ -12,15 +12,22 @@ on:
12
12
  - minor
13
13
  - patch
14
14
  default: auto
15
+ workflow_call:
16
+ inputs:
17
+ dry-run:
18
+ description: Assemble the release without pushing, for the pull request gate
19
+ type: boolean
20
+ required: true
15
21
  permissions: {}
16
22
  jobs:
17
23
  prepare:
18
24
  name: 🏷️ Prepare Towncrier release
19
- uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0
25
+ uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@0c4cffd63033d38196319524d101caae1eef925b # 6.7.2
20
26
  permissions:
21
27
  contents: read
22
28
  pull-requests: read
23
29
  secrets:
24
30
  RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
25
31
  with:
26
- bump: ${{ inputs.bump }}
32
+ bump: ${{ inputs.bump || 'auto' }}
33
+ dry-run: ${{ inputs.dry-run == true }}
@@ -2,3 +2,7 @@
2
2
 
3
3
  See the [contributing guide](https://pyproject-api.readthedocs.io/en/latest/contributing.html) for release note rules
4
4
  and local checks.
5
+
6
+ ## Tests
7
+
8
+ Add a test for every new feature and bug fix. A change without a test needs a reason in the pull request description.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyproject-api
3
- Version: 1.12.4
3
+ Version: 1.12.5
4
4
  Import-Name: pyproject_api
5
5
  Summary: API to interact with the python pyproject.toml based projects
6
6
  Project-URL: Changelog, https://github.com/tox-dev/pyproject-api/blob/main/docs/changelog.rst
@@ -16,6 +16,14 @@ backend runs from the project directory. Result paths retain the spelling of the
16
16
  directory argument. Configuration settings and generic command arguments are
17
17
  passed through unchanged.
18
18
 
19
+ Metadata preparation replaces a symlink used as the output directory with a real directory, leaving the link target
20
+ untouched. Dangling links are replaced too. Paths resolving to the project root or one of its ancestors are still
21
+ rejected before cleanup.
22
+
23
+ Metadata preparation clears the output directory before calling the metadata hook. If cleanup fails, the filesystem
24
+ error propagates and the hook is not called. When extracting metadata from a built wheel, cleanup errors stop extraction
25
+ after the wheel has been built.
26
+
19
27
  .. autoclass:: OptionalHooks
20
28
 
21
29
  Exceptions
@@ -5,6 +5,22 @@ Release History
5
5
 
6
6
  .. towncrier release notes start
7
7
 
8
+ .. _release-1-12-5:
9
+
10
+ v1.12.5 (2026-11-07)
11
+ --------------------
12
+
13
+ .. _release-1-12-5-fixed:
14
+
15
+ Fixed
16
+ ^^^^^
17
+
18
+ - Propagate filesystem errors when clearing nested metadata directories (:pr:`357`) by :user:`Quinnod345` and
19
+ :user:`gaborbernat`.
20
+ - Replace a symlink passed to ``ensure_empty_dir`` instead of following it, so a dangling link no longer raises
21
+ ``FileExistsError`` and a link to a directory no longer has its target emptied (:pr:`354`) by :user:`abhishekcpp007`
22
+ and :user:`gaborbernat`.
23
+
8
24
  .. _release-1-12-4:
9
25
 
10
26
  v1.12.4 (2026-11-04)
@@ -362,7 +362,7 @@ class Frontend(ABC):
362
362
  if metadata_path in project_root.parents:
363
363
  ancestor_message: Final[str] = f"the metadata directory can't contain the project root {self._root}"
364
364
  raise RuntimeError(ancestor_message)
365
- if metadata_directory.exists(): # start with fresh
365
+ if metadata_directory.is_symlink() or metadata_directory.exists():
366
366
  ensure_empty_dir(metadata_directory)
367
367
  metadata_directory.mkdir(parents=True, exist_ok=True)
368
368
 
@@ -502,10 +502,7 @@ class Frontend(ABC):
502
502
  if not root.endswith(".dist-info"):
503
503
  continue
504
504
  if basename is None:
505
- destination: Final[Path] = metadata_directory / root
506
- if destination.is_symlink(): # pragma: win32 no cover
507
- destination.unlink()
508
- ensure_empty_dir(destination)
505
+ ensure_empty_dir(metadata_directory / root)
509
506
  basename = root
510
507
  zip_file.extract(name, extract_to)
511
508
  if basename is None: # pragma: no branch
@@ -8,11 +8,14 @@ if TYPE_CHECKING:
8
8
 
9
9
 
10
10
  def ensure_empty_dir(path: Path) -> None:
11
- if path.exists():
11
+ if path.is_symlink():
12
+ path.unlink()
13
+ path.mkdir()
14
+ elif path.exists():
12
15
  if path.is_dir():
13
16
  for sub_path in path.iterdir():
14
17
  if sub_path.is_dir() and not sub_path.is_symlink():
15
- rmtree(sub_path, ignore_errors=True)
18
+ rmtree(sub_path)
16
19
  else:
17
20
  sub_path.unlink()
18
21
  else:
@@ -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.12.4'
22
- __version_tuple__ = version_tuple = (1, 12, 4)
21
+ __version__ = version = '1.12.5'
22
+ __version_tuple__ = version_tuple = (1, 12, 5)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -585,6 +585,33 @@ def test_prepare_metadata_allows_sibling_directory(tmp_path: Path, hook: str) ->
585
585
  assert list(metadata_directory.iterdir()) == []
586
586
 
587
587
 
588
+ @pytest.mark.parametrize(
589
+ "hook",
590
+ [
591
+ pytest.param("prepare_metadata_for_build_wheel", id="wheel"),
592
+ pytest.param("prepare_metadata_for_build_editable", id="editable"),
593
+ ],
594
+ )
595
+ @pytest.mark.parametrize(
596
+ "target_name", [pytest.param("missing", id="dangling"), pytest.param("shared", id="directory")]
597
+ )
598
+ def test_prepare_metadata_replaces_symlink(tmp_path: Path, hook: str, target_name: str) -> None:
599
+ root: Final[Path] = tmp_path / "project"
600
+ root.mkdir()
601
+ (root / "build_tester.py").write_text("")
602
+ shared: Final[Path] = tmp_path / "shared"
603
+ shared.mkdir()
604
+ (shared / "keep.txt").write_text("keep me")
605
+ metadata_directory: Final[Path] = tmp_path / "project-metadata"
606
+ metadata_directory.symlink_to(tmp_path / target_name, target_is_directory=True)
607
+
608
+ assert getattr(SubprocessFrontend(root, (root,), "build_tester", None, ()), hook)(metadata_directory) is None
609
+
610
+ assert not metadata_directory.is_symlink()
611
+ assert list(metadata_directory.iterdir()) == []
612
+ assert (shared / "keep.txt").read_text() == "keep me"
613
+
614
+
588
615
  def test_no_wheel_prepare_metadata_for_build_wheel(local_builder: Callable[[str], Path]) -> None:
589
616
  txt = "def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): return 'out'"
590
617
  tmp_path = local_builder(txt)
@@ -135,8 +135,7 @@ def test_setuptools_exception(frontend_setuptools: SubprocessFrontend) -> None:
135
135
 
136
136
  def test_bad_message(frontend_setuptools: SubprocessFrontend, tmp_path: Path) -> None:
137
137
  with frontend_setuptools._send_msg("bad_cmd", tmp_path / "a", "{{") as status: # ruff:ignore[private-member-access]
138
- while not status.done: # pragma: no branch
139
- pass
138
+ status.join()
140
139
  out, err = status.out_err()
141
140
  assert out
142
141
  assert "Backend: incorrect request to backend: bytearray(b'{{')" in err
@@ -2,11 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ import pytest
6
+
5
7
  from pyproject_api._util import ensure_empty_dir
6
8
 
7
9
  if TYPE_CHECKING:
8
10
  from pathlib import Path
9
11
 
12
+ from pytest_mock import MockerFixture
13
+
10
14
 
11
15
  def test_ensure_empty_dir_on_empty(tmp_path: Path) -> None:
12
16
  ensure_empty_dir(tmp_path)
@@ -45,6 +49,29 @@ def test_ensure_empty_dir_on_path_folder(tmp_path: Path) -> None:
45
49
  assert list(tmp_path.iterdir()) == []
46
50
 
47
51
 
52
+ @pytest.mark.parametrize(
53
+ ("target_name", "target_is_directory"),
54
+ [
55
+ pytest.param("missing", False, id="dangling-file"),
56
+ pytest.param("missing", True, id="dangling-directory"),
57
+ pytest.param("file", False, id="file"),
58
+ pytest.param("folder", True, id="directory"),
59
+ ],
60
+ )
61
+ def test_ensure_empty_dir_replaces_symlink(tmp_path: Path, target_name: str, target_is_directory: bool) -> None:
62
+ (tmp_path / "file").write_text("keep file")
63
+ folder = tmp_path / "folder"
64
+ folder.mkdir()
65
+ (folder / "c").write_text("keep folder contents")
66
+ path = tmp_path / "a"
67
+ path.symlink_to(tmp_path / target_name, target_is_directory=target_is_directory)
68
+ ensure_empty_dir(path)
69
+ assert not path.is_symlink()
70
+ assert list(path.iterdir()) == []
71
+ assert (tmp_path / "file").read_text() == "keep file"
72
+ assert (folder / "c").read_text() == "keep folder contents"
73
+
74
+
48
75
  def test_ensure_empty_dir_on_path_symlink_to_folder(tmp_path: Path) -> None:
49
76
  target = tmp_path / "target"
50
77
  target.mkdir()
@@ -55,3 +82,17 @@ def test_ensure_empty_dir_on_path_symlink_to_folder(tmp_path: Path) -> None:
55
82
  ensure_empty_dir(path)
56
83
  assert list(path.iterdir()) == []
57
84
  assert list(target.iterdir()) == [target / "c"]
85
+
86
+
87
+ def test_nested_cleanup_error_propagates(tmp_path: Path, mocker: MockerFixture) -> None:
88
+ blocked = tmp_path / "blocked"
89
+ blocked.mkdir()
90
+ stale = blocked / "stale.txt"
91
+ stale.write_text("stale metadata")
92
+ failure = PermissionError("file removal refused")
93
+ mocker.patch("os.unlink", autospec=True, side_effect=failure)
94
+ with pytest.raises(PermissionError) as captured:
95
+ ensure_empty_dir(tmp_path)
96
+
97
+ assert captured.value is failure
98
+ assert stale.read_text() == "stale metadata"
File without changes
File without changes
File without changes