Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion constructor/build_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,29 @@

from conda.base.constants import UNKNOWN_CHANNEL
from conda.common.url import remove_auth, split_anaconda_token
from conda.core.prefix_data import PrefixGraph
from conda.core.prefix_data import PrefixData, PrefixGraph
from conda.exports import default_prefix

from . import __version__
from .conda_interface import VersionOrder

logger = logging.getLogger(__name__)


def get_build_env_records(prefix=None):
"""Return the package records for the environment building the installer.

Defaults to the currently active conda environment (`default_prefix`,
i.e. the one running constructor) if no prefix is given. Not to be
confused with construct.yaml's unrelated `default_prefix` setting,
which is the end user's install location.
"""
if prefix is None:
prefix = default_prefix
# interoperability=True also picks up pip-installed packages, not just conda ones.
return list(PrefixData(prefix, interoperability=True).iter_records())


def _validate_output(output):
if isinstance(output, str):
output = {output: None}
Expand Down Expand Up @@ -85,6 +100,8 @@ def _serialize(obj):
else:
return repr(obj)

# Packages installed in the environment running constructor.
info["_build_environment_packages"] = get_build_env_records()
outpath = os.path.join(info["_output_dir"], "info.json")
with open(outpath, "w") as f:
json.dump(info, f, indent=2, default=_serialize)
Expand Down
19 changes: 19 additions & 0 deletions news/1314-build-environment-info
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* Add `_build_environment_packages` to `info.json`, listing the packages (including pip-installed ones) present in the environment used to build the installer. (#1314)

### Bug fixes

* <news item>

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>
127 changes: 127 additions & 0 deletions tests/test_conda_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import json

import pytest
from conda.base.context import context
from conda.common.path.python import get_python_site_packages_short_path
from conda.core.prefix_data import PrefixData
from conda.models.records import PackageRecord, PrefixRecord

from constructor.build_outputs import get_build_env_records

# Match the current platform, since tests run on multiple platforms
SUBDIR = context.subdir


def _make_package_record(name, version="1.2.3", build_number=0):
"""Make a dummy package record for test fixtures."""
return PackageRecord(
name=name,
version=version,
build=str(build_number),
build_number=build_number,
channel=None,
subdir=SUBDIR,
fn=f"{name}-{version}-{build_number}.conda",
)


def _fake_prefix_data(tmp_path, records):
"""Build a PrefixData whose in-memory records are injected directly,
so no conda-meta files are ever written to disk. Approach adapted from
conda/conda/testing/helpers.py::_get_solver_base, which patches the
same private `__prefix_records` attribute for the same reason."""
prefix_data = PrefixData(str(tmp_path))
prefix_data._PrefixData__prefix_records = {
rec.name: PrefixRecord.from_objects(rec) for rec in records
}
return prefix_data


@pytest.fixture
def patch_prefix_data(monkeypatch):
"""Patch constructor.build_outputs.PrefixData so get_build_env_records()
returns records we control, without touching disk."""

def _patch(records):
# Replace PrefixData itself with this function, so calling
# PrefixData(prefix) returns our fake object instead of reading
# real conda-meta files. Reuse the same fake per prefix instead of
# building a new one each call.
fake_instances = {}

def _fake_prefix_data_for(prefix, **kwargs):
if prefix not in fake_instances:
fake_instances[prefix] = _fake_prefix_data(prefix, records)
return fake_instances[prefix]

monkeypatch.setattr("constructor.build_outputs.PrefixData", _fake_prefix_data_for)

return _patch


@pytest.mark.parametrize(
"records",
[
pytest.param([], id="empty-environment"),
pytest.param([_make_package_record("numpy")], id="single-package"),
pytest.param(
[
_make_package_record("numpy"),
_make_package_record("foobar", version="24.11.0"),
],
id="multiple-packages",
),
],
)
def test_get_build_env_records_with_explicit_prefix(tmp_path, patch_prefix_data, records):
patch_prefix_data(records)

result = get_build_env_records(prefix=str(tmp_path))

assert sorted(rec.name for rec in result) == sorted(rec.name for rec in records)


def test_get_build_env_records_defaults_to_active_environment(
monkeypatch, tmp_path, patch_prefix_data
):
"""When prefix is not given, it must fall back to conda.exports.default_prefix
(the environment currently running constructor), not construct.yaml's
unrelated 'default_prefix' install-location setting."""
monkeypatch.setattr("constructor.build_outputs.default_prefix", str(tmp_path))
patch_prefix_data([_make_package_record("foobar", version="24.11.0")])

result = get_build_env_records()

assert [rec.name for rec in result] == ["foobar"]


def test_get_build_env_records_includes_pip_installed_packages(tmp_path):
"""Verify also pip packages are included among build environment deps."""
meta_dir = tmp_path / "conda-meta"
meta_dir.mkdir()

# Mock a conda package and a package installed via pip
python_record = PrefixRecord(
name="python",
version="1.2.3",
build="0",
build_number=0,
channel=None,
subdir=SUBDIR,
fn="python-1.2.3-0.conda",
paths_data={"paths": [], "paths_version": 1},
files=[],
)
(meta_dir / "python-1.2.3-0.json").write_text(json.dumps(python_record.dump()))

site_packages = tmp_path / get_python_site_packages_short_path("1.2")
site_packages.mkdir(parents=True)
dist_info = site_packages / "fakepkg-1.0.0.dist-info"
dist_info.mkdir()
(dist_info / "METADATA").write_text("Metadata-Version: 2.1\nName: fakepkg\nVersion: 1.0.0\n")
(dist_info / "INSTALLER").write_text("pip\n")
(dist_info / "RECORD").write_text("")

result = get_build_env_records(prefix=str(tmp_path))

assert sorted(rec.name for rec in result) == ["fakepkg", "python"]
8 changes: 8 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -2005,6 +2005,14 @@ def test_output_files(tmp_path, installer_type):
# Test that info.json contains serialized objects
info_json = json.loads((root_path / "info.json").read_text())
assert isinstance(info_json.get("_conda_exe_version"), str)
_build_environment_packages = info_json.get("_build_environment_packages")
assert isinstance(_build_environment_packages, list), (
"Build environment packages is not a list."
)
assert len(_build_environment_packages) > 0, "Build environment packages is empty."
assert isinstance(_build_environment_packages[0], dict), (
"Build environment package not serialized."
)
_all_pkg_records = info_json.get("_all_pkg_records")
assert isinstance(_all_pkg_records, list), "Package record is not a list."
assert len(_all_pkg_records) > 0, "Package record is empty."
Expand Down
Loading