Skip to content

Commit fdf55b3

Browse files
Merge pull request #1226 from dhellmann/task-specific-work-items
refactor(bootstrapper): decompose monolithic bootstrapper into a subpackage
2 parents 2e7c9cd + 8d117d2 commit fdf55b3

17 files changed

Lines changed: 3805 additions & 2850 deletions

src/fromager/bootstrapper.py

Lines changed: 0 additions & 2278 deletions
This file was deleted.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from ._bootstrapper import Bootstrapper
2+
from ._types import _DEFAULT_BG_THREADS
3+
4+
__all__ = ["_DEFAULT_BG_THREADS", "Bootstrapper"]

src/fromager/bootstrapper/_bootstrapper.py

Lines changed: 1286 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import pathlib
5+
import typing
6+
7+
from .. import finders, server, sources, wheels
8+
from ._phase import Phase
9+
from ._process_install_deps import ProcessInstallDeps
10+
from ._types import BootstrapPhase, SourceBuildResult
11+
12+
if typing.TYPE_CHECKING:
13+
from .. import context
14+
from ._bootstrapper import Bootstrapper
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class Build(Phase):
20+
"""Install remaining build deps and produce a wheel or sdist for the package.
21+
22+
Three execution paths based on ``WorkItem`` state:
23+
24+
- **Cached wheel**: a matching wheel was found during source preparation;
25+
no build is performed.
26+
- **sdist-only**: ``sdist_only`` mode is active and this is not a build
27+
requirement; only an sdist is built.
28+
- **Full build**: an sdist is built first, then a wheel is compiled and
29+
the local mirror is updated.
30+
31+
When ``pbi.exclusive_build`` is set the bootstrap loop drains the
32+
background thread pool before calling ``run()`` so the build has exclusive
33+
access to the environment.
34+
35+
Next phase: ``ProcessInstallDeps``.
36+
"""
37+
38+
phase: typing.ClassVar[BootstrapPhase] = BootstrapPhase.BUILD
39+
tracks_why: typing.ClassVar[bool] = True
40+
41+
@property
42+
def requires_exclusive_run(self) -> bool:
43+
return self.work_item.exclusive_build
44+
45+
def run(self, bt: Bootstrapper) -> list[Phase]:
46+
"""BUILD phase: install remaining deps, build wheel/sdist.
47+
48+
Returns:
49+
[ProcessInstallDeps].
50+
"""
51+
wi = self.work_item
52+
assert wi.resolved_version is not None
53+
assert wi.build_env is not None
54+
assert wi.sdist_root_dir is not None
55+
56+
# Install backend+sdist deps if disjoint from system deps
57+
remaining_deps = wi.build_backend_deps | wi.build_sdist_deps
58+
if remaining_deps.isdisjoint(wi.build_system_deps):
59+
wi.build_env.install(remaining_deps)
60+
61+
wheel_filename, sdist_filename = self.do_build(bt.ctx, bt.explain)
62+
63+
source_type = sources.get_source_type(bt.ctx, wi.req)
64+
65+
wi.build_result = SourceBuildResult(
66+
wheel_filename=wheel_filename,
67+
sdist_filename=sdist_filename,
68+
unpack_dir=wi.sdist_root_dir.parent,
69+
sdist_root_dir=wi.sdist_root_dir,
70+
build_env=wi.build_env,
71+
source_type=source_type,
72+
)
73+
74+
return [ProcessInstallDeps(wi)]
75+
76+
def _build_sdist(self, ctx: context.WorkContext) -> pathlib.Path:
77+
"""Build or locate an sdist for this item's package.
78+
79+
Checks ``ctx.sdists_builds`` for an existing sdist before invoking
80+
``sources.build_sdist()``.
81+
"""
82+
wi = self.work_item
83+
assert wi.resolved_version is not None
84+
assert wi.sdist_root_dir is not None
85+
assert wi.build_env is not None
86+
find_sdist_result = finders.find_sdist(
87+
ctx, ctx.sdists_builds, wi.req, str(wi.resolved_version)
88+
)
89+
if not find_sdist_result:
90+
sdist_filename: pathlib.Path = sources.build_sdist(
91+
ctx=ctx,
92+
req=wi.req,
93+
version=wi.resolved_version,
94+
sdist_root_dir=wi.sdist_root_dir,
95+
build_env=wi.build_env,
96+
)
97+
else:
98+
sdist_filename = find_sdist_result
99+
logger.info(
100+
f"have sdist version {wi.resolved_version}: {find_sdist_result}"
101+
)
102+
return sdist_filename
103+
104+
def _build_wheel(
105+
self,
106+
ctx: context.WorkContext,
107+
explain: str = "",
108+
) -> tuple[pathlib.Path, pathlib.Path]:
109+
"""Build a wheel for this item's package.
110+
111+
Calls :meth:`_build_sdist` first, then invokes ``wheels.build_wheel()``
112+
and updates the local wheel mirror. Returns ``(wheel_filename,
113+
sdist_filename)`` where *wheel_filename* is the path in
114+
``ctx.wheels_downloads`` after the mirror update.
115+
"""
116+
wi = self.work_item
117+
assert wi.resolved_version is not None
118+
assert wi.sdist_root_dir is not None
119+
assert wi.build_env is not None
120+
sdist_filename = self._build_sdist(ctx)
121+
122+
logger.info(f"starting build of {explain} for {ctx.variant}")
123+
built_filename = wheels.build_wheel(
124+
ctx=ctx,
125+
req=wi.req,
126+
sdist_root_dir=wi.sdist_root_dir,
127+
version=wi.resolved_version,
128+
build_env=wi.build_env,
129+
)
130+
server.update_wheel_mirror(ctx)
131+
# When we update the mirror, the built file moves to the downloads directory.
132+
wheel_filename = ctx.wheels_downloads / built_filename.name
133+
logger.info(f"built wheel for version {wi.resolved_version}: {wheel_filename}")
134+
return wheel_filename, sdist_filename
135+
136+
def do_build(
137+
self,
138+
ctx: context.WorkContext,
139+
explain: str = "",
140+
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
141+
"""Build a wheel or sdist for this item's package.
142+
143+
Returns ``(wheel_filename, sdist_filename)``. Either value may be
144+
``None`` depending on the build path:
145+
146+
- Cached wheel: ``(cached_wheel_filename, None)``
147+
- sdist-only: ``(None, sdist_filename)``
148+
- Full wheel: ``(wheel_filename, sdist_filename)``
149+
"""
150+
wi = self.work_item
151+
if wi.cached_wheel_filename:
152+
logger.debug(
153+
f"getting install requirements from cached wheel {wi.cached_wheel_filename.name}"
154+
)
155+
return wi.cached_wheel_filename, None
156+
elif wi.build_sdist_only:
157+
logger.debug(
158+
f"getting install requirements from sdist {wi.req.name}=={wi.resolved_version}"
159+
)
160+
return None, self._build_sdist(ctx)
161+
else:
162+
logger.debug(
163+
f"building wheel {wi.req.name}=={wi.resolved_version} to get install requirements"
164+
)
165+
return self._build_wheel(ctx, explain)
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import os
5+
import pathlib
6+
import typing
7+
import zipfile
8+
from urllib.parse import urlparse
9+
10+
import requests.exceptions
11+
from packaging.requirements import Requirement
12+
from packaging.version import Version
13+
from resolvelib.resolvers import ResolverException
14+
15+
from .. import dependencies, finders, resolver, server, wheels
16+
from ..requirements_file import RequirementType
17+
from ._types import PreparedSourceData
18+
19+
if typing.TYPE_CHECKING:
20+
from .. import context
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def _create_unpack_dir(
26+
work_dir: pathlib.Path,
27+
req: Requirement,
28+
resolved_version: Version,
29+
) -> pathlib.Path:
30+
unpack_dir = work_dir / f"{req.name}-{resolved_version}"
31+
unpack_dir.mkdir(parents=True, exist_ok=True)
32+
return unpack_dir
33+
34+
35+
def _extract_build_reqs_from_wheel(
36+
work_dir: pathlib.Path,
37+
req: Requirement,
38+
resolved_version: Version,
39+
wheel_filename: pathlib.Path,
40+
) -> pathlib.Path | None:
41+
"""Extract fromager build requirement files from a wheel archive.
42+
43+
Looks for files prefixed with `FROMAGER_BUILD_REQ_PREFIX` inside the
44+
wheel's `.dist-info` directory and extracts them to a local unpack
45+
directory. Returns the unpack directory on success, or None if the
46+
files are not present or extraction fails.
47+
"""
48+
dist_name, dist_version, _, _ = wheels.extract_info_from_wheel_file(
49+
req, wheel_filename
50+
)
51+
unpack_dir = _create_unpack_dir(work_dir, req, resolved_version)
52+
dist_filename = f"{dist_name}-{dist_version}"
53+
dist_info_path = pathlib.Path(f"{dist_filename}.dist-info")
54+
req_filenames: list[str] = [
55+
dependencies.BUILD_BACKEND_REQ_FILE_NAME,
56+
dependencies.BUILD_SDIST_REQ_FILE_NAME,
57+
dependencies.BUILD_SYSTEM_REQ_FILE_NAME,
58+
]
59+
try:
60+
with zipfile.ZipFile(wheel_filename) as archive:
61+
for filename in req_filenames:
62+
zipinfo = archive.getinfo(
63+
str(
64+
dist_info_path
65+
/ f"{wheels.FROMAGER_BUILD_REQ_PREFIX}-{filename}"
66+
)
67+
)
68+
if os.path.isabs(zipinfo.filename) or ".." in zipinfo.filename:
69+
raise ValueError(f"Unsafe path in wheel: {zipinfo.filename}")
70+
zipinfo.filename = filename
71+
output_file = archive.extract(zipinfo, unpack_dir)
72+
logger.info(f"extracted {output_file}")
73+
logger.info(f"extracted build requirements from wheel into {unpack_dir}")
74+
return unpack_dir
75+
except Exception as e:
76+
logger.info(f"could not extract build requirements from wheel: {e}")
77+
for filename in req_filenames:
78+
unpack_dir.joinpath(filename).unlink(missing_ok=True)
79+
return None
80+
81+
82+
def _look_for_existing_wheel(
83+
ctx: context.WorkContext,
84+
req: Requirement,
85+
resolved_version: Version,
86+
search_in: pathlib.Path,
87+
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
88+
pbi = ctx.package_build_info(req)
89+
expected_build_tag = pbi.build_tag(resolved_version)
90+
logger.info(
91+
f"looking for existing wheel for version {resolved_version} with build tag {expected_build_tag} in {search_in}"
92+
)
93+
wheel_filename = finders.find_wheel(
94+
downloads_dir=search_in,
95+
req=req,
96+
dist_version=str(resolved_version),
97+
build_tag=expected_build_tag,
98+
)
99+
if not wheel_filename:
100+
return None, None
101+
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheel_filename)
102+
if expected_build_tag and expected_build_tag != build_tag:
103+
logger.info(
104+
f"found wheel for {resolved_version} in {wheel_filename} but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
105+
)
106+
return None, None
107+
logger.info(f"found existing wheel {wheel_filename}")
108+
build_reqs_dir = _extract_build_reqs_from_wheel(
109+
ctx.work_dir, req, resolved_version, wheel_filename
110+
)
111+
return wheel_filename, build_reqs_dir
112+
113+
114+
def _download_wheel_from_cache(
115+
ctx: context.WorkContext,
116+
cache_wheel_server_url: str | None,
117+
req: Requirement,
118+
resolved_version: Version,
119+
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
120+
if not cache_wheel_server_url:
121+
return None, None
122+
logger.info(f"checking if wheel was already uploaded to {cache_wheel_server_url}")
123+
try:
124+
pinned_req = Requirement(f"{req.name}=={resolved_version}")
125+
provider = finders.PyPICacheProvider(
126+
cache_server_url=cache_wheel_server_url,
127+
constraints=ctx.constraints,
128+
)
129+
results = resolver.find_all_matching_from_provider(provider, pinned_req)
130+
wheel_url, _ = results[0]
131+
wheelfile_name = pathlib.Path(urlparse(wheel_url).path)
132+
pbi = ctx.package_build_info(req)
133+
expected_build_tag = pbi.build_tag(resolved_version)
134+
logger.info(f"has expected build tag {expected_build_tag}")
135+
changelogs = pbi.get_changelog(resolved_version)
136+
logger.debug(f"has change logs {changelogs}")
137+
138+
_, _, build_tag, _ = wheels.extract_info_from_wheel_file(req, wheelfile_name)
139+
if expected_build_tag and expected_build_tag != build_tag:
140+
logger.info(
141+
f"found wheel for {resolved_version} in cache but build tag does not match. Got {build_tag} but expected {expected_build_tag}"
142+
)
143+
return None, None
144+
145+
cached_wheel = wheels.download_wheel(
146+
req=req, wheel_url=wheel_url, output_directory=ctx.wheels_downloads
147+
)
148+
if cache_wheel_server_url != ctx.wheel_server_url:
149+
server.update_wheel_mirror(ctx)
150+
logger.info("found built wheel on cache server")
151+
unpack_dir = _extract_build_reqs_from_wheel(
152+
ctx.work_dir, req, resolved_version, cached_wheel
153+
)
154+
return cached_wheel, unpack_dir
155+
except ResolverException:
156+
logger.info(
157+
f"did not find wheel for {resolved_version} in {cache_wheel_server_url}"
158+
)
159+
return None, None
160+
except requests.exceptions.RequestException as err:
161+
logger.warning(
162+
f"network error checking wheel cache for {resolved_version} "
163+
f"at {cache_wheel_server_url}: {err}"
164+
)
165+
return None, None
166+
except Exception as err:
167+
logger.warning(
168+
f"unexpected error checking wheel cache for {resolved_version} "
169+
f"at {cache_wheel_server_url}: {err}"
170+
)
171+
return None, None
172+
173+
174+
def _find_cached_wheel(
175+
ctx: context.WorkContext,
176+
cache_wheel_server_url: str | None,
177+
req: Requirement,
178+
resolved_version: Version,
179+
) -> tuple[pathlib.Path | None, pathlib.Path | None]:
180+
"""Look for cached wheel in 3 locations (thread-safe, no Bootstrapper state).
181+
182+
Checks for cached wheels in order:
183+
1. wheels_build directory (previously built)
184+
2. wheels_downloads directory (previously downloaded)
185+
3. Cache server (remote cache)
186+
187+
Returns:
188+
Tuple of (cached_wheel_filename, unpacked_cached_wheel).
189+
Both None if no cache hit.
190+
"""
191+
cached_wheel, unpacked = _look_for_existing_wheel(
192+
ctx, req, resolved_version, ctx.wheels_build
193+
)
194+
if cached_wheel:
195+
return cached_wheel, unpacked
196+
197+
cached_wheel, unpacked = _look_for_existing_wheel(
198+
ctx, req, resolved_version, ctx.wheels_downloads
199+
)
200+
if cached_wheel:
201+
return cached_wheel, unpacked
202+
203+
cached_wheel, unpacked = _download_wheel_from_cache(
204+
ctx, cache_wheel_server_url, req, resolved_version
205+
)
206+
if cached_wheel:
207+
return cached_wheel, unpacked
208+
209+
return None, None
210+
211+
212+
def _bg_prepare_prebuilt(
213+
ctx: context.WorkContext,
214+
req: Requirement,
215+
req_type: RequirementType,
216+
resolved_version: Version,
217+
wheel_url: str,
218+
) -> PreparedSourceData:
219+
"""Background-safe prebuilt download: no Bootstrapper state accessed."""
220+
# Thread-safe: paths include {req.name}-{resolved_version} (unique per package),
221+
# mkdir uses exist_ok=True (atomic), and update_wheel_mirror() is already locked.
222+
logger.info(f"using pre-built wheel for {req_type} requirement")
223+
wheel_filename = wheels.download_wheel(req, wheel_url, ctx.wheels_prebuilt)
224+
unpack_dir = _create_unpack_dir(ctx.work_dir, req, resolved_version)
225+
server.update_wheel_mirror(ctx)
226+
return PreparedSourceData(wheel_filename=wheel_filename, unpack_dir=unpack_dir)

0 commit comments

Comments
 (0)