Skip to content

Commit c495428

Browse files
tiranclaude
andcommitted
refactor(downloads): centralize download helpers in new module
Create `fromager.downloads` as a single import point for all download helpers, avoiding cyclic import issues between `sources`, `wheels`, and `resolver`. Move `download_url` and `extract_filename_from_url` from `sources` and `resolver` into the new module. Add `download_sdist` and `download_wheel` that validate filenames with `parse_sdist_filename` / `parse_wheel_filename` before downloading and verify archives are non-empty afterwards. Add `download_git_source` for cloning from pip VCS URLs using `git_clone_fast` with destination directory conflict detection. Improve `download_url` temp file handling: use `NamedTemporaryFile` in the target directory with `os.rename` for atomic moves. Replace `_download_source_check` and `_download_wheel_check` with the new `download_sdist` and `download_wheel` functions. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Christian Heimes <cheimes@redhat.com>
1 parent c9ddc51 commit c495428

9 files changed

Lines changed: 476 additions & 207 deletions

File tree

src/fromager/commands/build.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
clickext,
2424
context,
2525
dependency_graph,
26+
downloads,
2627
hooks,
2728
metrics,
2829
overrides,
@@ -33,7 +34,6 @@
3334
wheels,
3435
)
3536

36-
from .. import resolver
3737
from ..log import VERBOSE_LOG_FMT, ThreadLogFilter, req_ctxvar_context
3838

3939
logger = logging.getLogger(__name__)
@@ -495,7 +495,7 @@ def _is_wheel_built(
495495
pbi = wkctx.package_build_info(req)
496496
build_tag_from_settings = pbi.build_tag(resolved_version)
497497
build_tag = build_tag_from_settings if build_tag_from_settings else (0, "")
498-
wheel_basename = resolver.extract_filename_from_url(url)
498+
wheel_basename = downloads.extract_filename_from_url(url)
499499
_, _, build_tag_from_name, _ = parse_wheel_filename(wheel_basename)
500500
existing_build_tag = build_tag_from_name if build_tag_from_name else (0, "")
501501
if (

src/fromager/downloads.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
import pathlib
6+
import tarfile
7+
import tempfile
8+
import typing
9+
import zipfile
10+
from urllib.parse import unquote, urlparse
11+
12+
from packaging.utils import parse_sdist_filename, parse_wheel_filename
13+
14+
from . import gitutils
15+
from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception
16+
from .request_session import session
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def extract_filename_from_url(url: str) -> str:
22+
"""Extract filename from URL and decode it."""
23+
path = urlparse(url).path
24+
filename = os.path.basename(path)
25+
return unquote(filename)
26+
27+
28+
def download_url(
29+
*,
30+
destination_dir: pathlib.Path,
31+
url: str,
32+
destination_filename: str | None = None,
33+
) -> pathlib.Path:
34+
"""Download a URL to destination_dir, returning the local path.
35+
36+
Returns immediately if the file already exists. Downloads to a
37+
temporary file in the same directory first and uses :func:`os.rename`
38+
for an atomic move on success, avoiding partial files. Retries on
39+
transient network errors.
40+
41+
.. versionadded:: 0.90.0
42+
"""
43+
basename = (
44+
destination_filename if destination_filename else extract_filename_from_url(url)
45+
)
46+
outfile = destination_dir / basename
47+
logger.debug(
48+
"looking for %s %s",
49+
outfile,
50+
"(exists)" if outfile.exists() else "(not there)",
51+
)
52+
if outfile.exists():
53+
logger.debug("already have %s", outfile)
54+
return outfile
55+
56+
destination_dir.mkdir(parents=True, exist_ok=True)
57+
58+
@retry_on_exception(
59+
exceptions=RETRYABLE_EXCEPTIONS,
60+
max_attempts=5,
61+
backoff_factor=1.5,
62+
max_backoff=120.0,
63+
)
64+
def _download_with_retry() -> pathlib.Path:
65+
"""Download to a temporary file, then atomically rename."""
66+
logger.debug("reading from %s", url)
67+
# NamedTemporaryFile in the same directory ensures os.rename() is
68+
# atomic (same filesystem). delete=False so we control cleanup.
69+
# Temp filename looks like ".tmp.foo-1.2.tar.gz.abc123def".
70+
tmp = tempfile.NamedTemporaryFile(
71+
dir=destination_dir,
72+
prefix=f".tmp.{basename}.",
73+
delete=False,
74+
)
75+
temp_path = pathlib.Path(tmp.name)
76+
try:
77+
with session.get(url, stream=True) as r:
78+
r.raise_for_status()
79+
logger.debug("writing to %s", temp_path)
80+
for chunk in r.iter_content(chunk_size=64 * 1024):
81+
if chunk:
82+
tmp.write(chunk)
83+
tmp.close()
84+
# Atomic rename on the same filesystem
85+
os.rename(temp_path, outfile)
86+
logger.info("saved %s", outfile)
87+
return outfile
88+
except BaseException:
89+
tmp.close()
90+
temp_path.unlink(missing_ok=True)
91+
raise
92+
93+
result: pathlib.Path = _download_with_retry()
94+
return result
95+
96+
97+
def download_sdist(
98+
*,
99+
destination_dir: pathlib.Path,
100+
url: str,
101+
destination_filename: str | None = None,
102+
) -> pathlib.Path:
103+
"""Download a source distribution and verify it.
104+
105+
Accepts ``.tar.gz`` and ``.zip`` sdists. Validates the filename
106+
with :func:`packaging.utils.parse_sdist_filename` before
107+
downloading and checks that the archive is non-empty afterwards.
108+
109+
.. versionadded:: 0.90.0
110+
"""
111+
basename = (
112+
destination_filename if destination_filename else extract_filename_from_url(url)
113+
)
114+
# Validates name, version, and extension (.tar.gz or .zip);
115+
# raises InvalidSdistFilename on malformed names.
116+
parse_sdist_filename(basename)
117+
118+
filepath = download_url(
119+
destination_dir=destination_dir,
120+
url=url,
121+
destination_filename=basename,
122+
)
123+
124+
if basename.endswith(".tar.gz"):
125+
with tarfile.open(filepath, mode="r:gz") as tar:
126+
if tar.next() is None:
127+
raise tarfile.TarError(f"empty tar file: {filepath}")
128+
elif basename.endswith(".zip"):
129+
with zipfile.ZipFile(filepath) as zf:
130+
if not zf.namelist():
131+
raise zipfile.BadZipFile(f"empty zip file: {filepath}")
132+
else:
133+
typing.assert_never(basename) # type: ignore[arg-type]
134+
135+
return filepath
136+
137+
138+
def download_wheel(
139+
*,
140+
destination_dir: pathlib.Path,
141+
url: str,
142+
destination_filename: str | None = None,
143+
) -> pathlib.Path:
144+
"""Download a wheel and verify it.
145+
146+
Validates that the filename is a valid wheel name (using
147+
:func:`packaging.utils.parse_wheel_filename`) before downloading,
148+
then checks that the zip archive is non-empty.
149+
150+
.. versionadded:: 0.90.0
151+
"""
152+
basename = (
153+
destination_filename if destination_filename else extract_filename_from_url(url)
154+
)
155+
# Validates name, version, build tag, tags, and .whl extension;
156+
# raises InvalidWheelFilename on malformed names.
157+
parse_wheel_filename(basename)
158+
159+
filepath = download_url(
160+
destination_dir=destination_dir,
161+
url=url,
162+
destination_filename=basename,
163+
)
164+
165+
with zipfile.ZipFile(filepath) as zf:
166+
if not zf.namelist():
167+
raise zipfile.BadZipFile(f"empty wheel file: {filepath}")
168+
169+
return filepath
170+
171+
172+
def download_git_source(
173+
*,
174+
destination_dir: pathlib.Path,
175+
vcs_url: str,
176+
require_ref: bool = True,
177+
) -> pathlib.Path:
178+
"""Clone a git repository from a pip VCS URL.
179+
180+
Parses *vcs_url* with :func:`~fromager.gitutils.parse_vcs_url`
181+
and clones using :func:`~fromager.gitutils.git_clone_fast`
182+
with recursive submodules.
183+
184+
Raises ``FileExistsError`` if the destination directory already
185+
exists.
186+
187+
.. versionadded:: 0.90.0
188+
"""
189+
repo_url, ref = gitutils.parse_vcs_url(vcs_url, require_ref=require_ref)
190+
191+
# git clone fails when the target directory is not empty.
192+
try:
193+
destination_dir.mkdir(parents=True, exist_ok=False)
194+
except FileExistsError:
195+
raise FileExistsError(
196+
f"destination directory {destination_dir} already exists, "
197+
f"cannot clone {vcs_url}"
198+
) from None
199+
gitutils.git_clone_fast(
200+
output_dir=destination_dir,
201+
repo_url=repo_url,
202+
ref=ref,
203+
)
204+
return destination_dir

src/fromager/gitutils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
from __future__ import annotations
2+
13
import logging
24
import pathlib
35
import typing
46
from urllib.parse import urlparse
57

68
from packaging.requirements import Requirement
79

8-
from fromager import context, external_commands
10+
from fromager import external_commands
11+
12+
if typing.TYPE_CHECKING:
13+
from fromager import context
914

1015
logger = logging.getLogger(__name__)
1116

src/fromager/resolver.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from collections.abc import Iterable
1515
from operator import attrgetter
1616
from platform import python_version
17-
from urllib.parse import quote, unquote, urlparse
17+
from urllib.parse import quote, urlparse
1818

1919
import pypi_simple
2020
import resolvelib
@@ -33,6 +33,7 @@
3333
from . import overrides
3434
from .candidate import Candidate, Cooldown
3535
from .constraints import Constraints
36+
from .downloads import extract_filename_from_url as extract_filename_from_url
3637
from .extras_provider import ExtrasProvider
3738
from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception
3839
from .request_session import session
@@ -204,13 +205,6 @@ def _compute_max_age_cutoff(
204205
return bootstrap_time - ctx.max_release_age
205206

206207

207-
def extract_filename_from_url(url: str) -> str:
208-
"""Extract filename from URL and decode it."""
209-
path = urlparse(url).path
210-
filename = os.path.basename(path)
211-
return unquote(filename)
212-
213-
214208
class LogReporter(resolvelib.BaseReporter):
215209
"""Report resolution events
216210

0 commit comments

Comments
 (0)