Skip to content

Commit 9a2e1e9

Browse files
authored
tests: download integration sources once per run (#1157)
Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent bf222f4 commit 9a2e1e9

5 files changed

Lines changed: 107 additions & 26 deletions

File tree

.github/workflows/reusable-pytest.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,28 @@ on:
33
workflow_call:
44
permissions: {}
55
jobs:
6+
integration-sources:
7+
name: Download integration sources
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
11+
with:
12+
persist-credentials: false
13+
- name: Download the sources
14+
run: python tests/integration_sources.py
15+
- name: Share the sources with the test matrix
16+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
17+
with:
18+
name: integration-sources
19+
path: .integration-sources
20+
retention-days: 1
21+
compression-level: 0 # the archives are compressed already
22+
include-hidden-files: true # .integration-sources is a hidden directory
23+
if-no-files-found: error # an empty artifact silently returns every job to its own download
624
pytest:
725
name: Run tests
26+
needs: integration-sources
27+
if: ${{ !cancelled() }}
828
runs-on: ${{ matrix.os }}-latest
929
env:
1030
PYTEST_ADDOPTS: "--run-integration --showlocals -vv --durations=10 --reruns 5 --only-rerun CalledProcessError --only-rerun RemoteDisconnected --only-rerun HTTPError"
@@ -34,6 +54,13 @@ jobs:
3454
with:
3555
fetch-depth: 0
3656
persist-credentials: false
57+
- name: Get the integration sources
58+
# a failure here is not fatal, because the test fixture downloads the sources itself
59+
continue-on-error: true
60+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
61+
with:
62+
name: integration-sources
63+
path: .integration-sources
3764
- name: Setup uv
3865
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
3966
- name: Install tox (uv)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ test-core = [
9696
"pytest-xdist >= 2.4.0",
9797
]
9898
test = [
99-
"filelock >= 3.4.0",
99+
"filelock >= 3.20.1",
100100
"covdefaults >= 2.3",
101101
"wheel >= 0.38.1",
102102
'setuptools >= 56.0.0; python_version == "3.10"',
@@ -152,7 +152,7 @@ exclude-newer = "7 days"
152152
run.plugins = ["covdefaults"]
153153
run.core = "ctrace" # sysmon is 3.14+'s default but can't record dynamic contexts; PyPy falls back to pytrace
154154
run.source = ["build", "tests"]
155-
run.omit = ["tests/conftest.py", "tests/test_integration.py",]
155+
run.omit = ["tests/conftest.py", "tests/test_integration.py", "tests/integration_sources.py",]
156156
report.omit = ["src/build/_types.py"]
157157
run.disable_warnings = [
158158
"module-not-measured", # Triggers in multithreaded context on build

tests/constraints.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
filelock==3.20.1
12
importlib-metadata==4.6
23
packaging==24.0
34
pip==22.3; python_version < "3.12"

tests/integration_sources.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SPDX-License-Identifier: MIT
2+
3+
"""The projects that the integration tests build, and the cache of their archives.
4+
5+
``tests/test_integration.py`` imports this module, and CI runs it as a script to fill the cache once for the whole test
6+
matrix. It uses the standard library only, so CI can run it before an install.
7+
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import http.client
13+
import os
14+
import shutil
15+
import time
16+
import urllib.request
17+
18+
from pathlib import Path
19+
20+
21+
INTEGRATION_SOURCES: dict[str, tuple[str, str]] = {
22+
'dateutil': ('dateutil/dateutil', '2.9.0'),
23+
'pip': ('pypa/pip', '25.0.1'),
24+
'Solaar': ('pwr-Solaar/Solaar', '1.1.14'),
25+
'flit': ('pypa/flit', '3.12.0'),
26+
}
27+
28+
STORE_DIR = Path(__file__).resolve().parents[1] / '.integration-sources'
29+
ATTEMPTS = 3
30+
NETWORK_ERRORS = (OSError, http.client.HTTPException)
31+
32+
33+
def download_archive(name: str) -> tuple[Path, str]:
34+
"""Put the archive in the cache and return its path and version. Hold a lock around this to make it parallel safe."""
35+
github_org_repo, version = INTEGRATION_SOURCES[name]
36+
target = STORE_DIR / f'{name}-{version}.tar.gz'
37+
if target.exists():
38+
return target, version
39+
40+
url = f'https://github.com/{github_org_repo}/archive/{version}.tar.gz'
41+
STORE_DIR.mkdir(exist_ok=True)
42+
# write to a temporary name, because GitHub can drop a large archive request and leave a truncated file behind
43+
partial = target.with_suffix('.part')
44+
for attempt in range(1, ATTEMPTS + 1):
45+
try:
46+
with urllib.request.urlopen(url) as request, partial.open('wb') as file_handler:
47+
shutil.copyfileobj(request, file_handler)
48+
except NETWORK_ERRORS as exception: # noqa: PERF203
49+
partial.unlink(missing_ok=True)
50+
if attempt == ATTEMPTS:
51+
raise
52+
print(f'attempt {attempt}/{ATTEMPTS} failed for {url}: {exception}')
53+
time.sleep(2**attempt)
54+
else:
55+
break
56+
os.replace(partial, target)
57+
return target, version
58+
59+
60+
def main() -> None:
61+
failed = []
62+
for name in INTEGRATION_SOURCES:
63+
try:
64+
print(f'ready: {download_archive(name)[0].name}')
65+
except NETWORK_ERRORS as exception: # noqa: PERF203
66+
print(f'failed: {name}: {exception}')
67+
failed.append(name)
68+
if failed:
69+
print(f'::warning::could not pre-download {", ".join(failed)}; the tests download these themselves')
70+
71+
72+
if __name__ == '__main__':
73+
main()

tests/test_integration.py

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import contextlib
65
import importlib.util
76
import os
87
import os.path
@@ -11,14 +10,15 @@
1110
import subprocess
1211
import sys
1312
import tarfile
14-
import urllib.request
1513

1614
from pathlib import Path
1715

1816
import filelock
1917
import pytest
2018
import pytest_mock
2119

20+
from integration_sources import STORE_DIR, download_archive
21+
2222
import build.__main__
2323

2424

@@ -27,13 +27,6 @@
2727
MISSING_UV = not shutil.which('uv')
2828

2929

30-
INTEGRATION_SOURCES = {
31-
'dateutil': ('dateutil/dateutil', '2.9.0'),
32-
'pip': ('pypa/pip', '25.0.1'),
33-
'Solaar': ('pwr-Solaar/Solaar', '1.1.14'),
34-
'flit': ('pypa/flit', '3.12.0'),
35-
}
36-
3730
_SDIST = re.compile('.*.tar.gz')
3831
_WHEEL = re.compile('.*.whl')
3932
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -58,21 +51,8 @@ def _ignore_folder(base: str, filenames: list[str]) -> list[str]:
5851
return dest
5952

6053
# for other projects download from github and cache it
61-
tar_store = os.path.join(ROOT, '.integration-sources')
62-
# Checking with exists is not parallel safe so just ignore,
63-
# if the creation failed we will have another failure soon
64-
# that will notify the user.
65-
with contextlib.suppress(OSError):
66-
os.makedirs(tar_store)
67-
68-
github_org_repo, version = INTEGRATION_SOURCES[name]
69-
tar_filename = f'{name}-{version}.tar.gz'
70-
tarball = os.path.join(tar_store, tar_filename)
71-
with filelock.FileLock(os.path.join(tar_store, f'{tar_filename}.lock')):
72-
if not os.path.exists(tarball):
73-
url = f'https://github.com/{github_org_repo}/archive/{version}.tar.gz'
74-
with urllib.request.urlopen(url) as request, open(tarball, 'wb') as file_handler:
75-
shutil.copyfileobj(request, file_handler)
54+
with filelock.FileLock(str(STORE_DIR / f'{name}.lock')):
55+
tarball, version = download_archive(name)
7656
with tarfile.open(tarball, 'r:gz') as tar_handler:
7757
tar_handler.extractall(str(dest))
7858
return dest / f'{name}-{version}'

0 commit comments

Comments
 (0)