diff --git a/libmamba/CMakeLists.txt b/libmamba/CMakeLists.txt index 15ee98e6ce..a0ca5095f7 100644 --- a/libmamba/CMakeLists.txt +++ b/libmamba/CMakeLists.txt @@ -270,6 +270,8 @@ set( ${LIBMAMBA_SOURCE_DIR}/api/configuration.cpp ${LIBMAMBA_SOURCE_DIR}/api/create.cpp ${LIBMAMBA_SOURCE_DIR}/api/env.cpp + ${LIBMAMBA_SOURCE_DIR}/api/environment_yaml.cpp + ${LIBMAMBA_SOURCE_DIR}/api/export.cpp ${LIBMAMBA_SOURCE_DIR}/api/info.cpp ${LIBMAMBA_SOURCE_DIR}/api/install.cpp ${LIBMAMBA_SOURCE_DIR}/api/list.cpp @@ -423,6 +425,8 @@ set( ${LIBMAMBA_INCLUDE_DIR}/mamba/api/constants.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/api/create.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/api/env.hpp + ${LIBMAMBA_INCLUDE_DIR}/mamba/api/environment_yaml.hpp + ${LIBMAMBA_INCLUDE_DIR}/mamba/api/export.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/api/info.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/api/install.hpp ${LIBMAMBA_INCLUDE_DIR}/mamba/api/list.hpp diff --git a/libmamba/include/mamba/api/environment_yaml.hpp b/libmamba/include/mamba/api/environment_yaml.hpp new file mode 100644 index 0000000000..b30c216ebc --- /dev/null +++ b/libmamba/include/mamba/api/environment_yaml.hpp @@ -0,0 +1,70 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. +// +// The full license is in the file LICENSE, distributed with this software. + +#ifndef MAMBA_API_ENVIRONMENT_YAML_HPP +#define MAMBA_API_ENVIRONMENT_YAML_HPP + +#include +#include +#include +#include + +#include "mamba/fs/filesystem.hpp" + +// Include install.hpp for other_pkg_mgr_spec definition (needed by yaml_file_contents) +#include "mamba/api/install.hpp" + +namespace mamba +{ + class Context; + class PrefixData; + + /** Options for converting prefix data to YAML export contents */ + struct PrefixToYamlOptions + { + bool no_builds = false; + bool ignore_channels = false; + bool include_md5 = false; + }; + + namespace detail + { + // yaml_file_contents is now defined here instead of install.hpp + struct yaml_file_contents + { + std::string name; + std::string prefix; + std::vector dependencies, channels; + std::map variables; + std::vector others_pkg_mgrs_specs; + }; + } + + // Convert PrefixData to yaml_file_contents + detail::yaml_file_contents prefix_to_yaml_contents( + const PrefixData& prefix_data, + const Context& ctx, + const std::string& env_name = "", + const PrefixToYamlOptions& options = {} + ); + + // Write yaml_file_contents to output stream + void yaml_contents_to_stream(const detail::yaml_file_contents& contents, std::ostream& out); + + // Write yaml_file_contents to YAML file + void + yaml_contents_to_file(const detail::yaml_file_contents& contents, const fs::u8path& yaml_file_path); + + // Read YAML file to yaml_file_contents + detail::yaml_file_contents file_to_yaml_contents( + const Context& ctx, + const std::string& yaml_file, + const std::string& platform, + bool use_uv = false + ); +} + +#endif diff --git a/libmamba/include/mamba/api/export.hpp b/libmamba/include/mamba/api/export.hpp new file mode 100644 index 0000000000..c6aaf797cb --- /dev/null +++ b/libmamba/include/mamba/api/export.hpp @@ -0,0 +1,19 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. +// +// The full license is in the file LICENSE, distributed with this software. + +#ifndef MAMBA_API_EXPORT_HPP +#define MAMBA_API_EXPORT_HPP + +#include "mamba/api/configuration.hpp" + +namespace mamba +{ + class Configuration; + + void export_environment(Configuration& config); +} + +#endif diff --git a/libmamba/include/mamba/api/install.hpp b/libmamba/include/mamba/api/install.hpp index 0306e2bcda..9d4f2b2784 100644 --- a/libmamba/include/mamba/api/install.hpp +++ b/libmamba/include/mamba/api/install.hpp @@ -112,23 +112,11 @@ namespace mamba bool operator==(const other_pkg_mgr_spec& s1, const other_pkg_mgr_spec& s2); - struct yaml_file_contents - { - std::string name; - std::vector dependencies, channels; - std::map variables; - std::vector others_pkg_mgrs_specs; - }; + // yaml_file_contents moved to environment_yaml.hpp + struct yaml_file_contents; // Forward declaration bool eval_selector(const std::string& selector, const std::string& platform); - yaml_file_contents read_yaml_file( - const Context& ctx, - const std::string& yaml_file, - const std::string& platform, - bool use_uv - ); - inline void to_json(nlohmann::json&, const other_pkg_mgr_spec&) { } diff --git a/libmamba/src/api/environment_yaml.cpp b/libmamba/src/api/environment_yaml.cpp new file mode 100644 index 0000000000..821f22d7c4 --- /dev/null +++ b/libmamba/src/api/environment_yaml.cpp @@ -0,0 +1,571 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. +// +// The full license is in the file LICENSE, distributed with this software. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mamba/api/env.hpp" +#include "mamba/api/environment_yaml.hpp" +#include "mamba/api/install.hpp" +#include "mamba/core/channel_context.hpp" +#include "mamba/core/context.hpp" +#include "mamba/core/output.hpp" +#include "mamba/core/prefix_data.hpp" +#include "mamba/core/util.hpp" +#include "mamba/download/downloader.hpp" +#include "mamba/fs/filesystem.hpp" +#include "mamba/specs/package_info.hpp" +#include "mamba/util/path_manip.hpp" +#include "mamba/util/string.hpp" + +namespace mamba +{ + namespace + { + // Helper function: Read environment variables from prefix state file + // Converts UPPERCASE keys (from state file) to lowercase (for YAML) + std::map read_env_vars_from_prefix(const fs::u8path& prefix) + { + std::map env_vars; + const fs::u8path state_file_path = prefix / "conda-meta" / "state"; + + if (fs::exists(state_file_path)) + { + auto fin = open_ifstream(state_file_path); + try + { + nlohmann::json j; + fin >> j; + if (j.contains("env_vars") && j["env_vars"].is_object()) + { + for (auto it = j["env_vars"].begin(); it != j["env_vars"].end(); ++it) + { + // Convert UPPERCASE keys from state file to lowercase for YAML + std::string key = it.key(); + std::string lower_key = util::to_lower(key); + env_vars[lower_key] = it.value().get(); + } + } + } + catch (nlohmann::json::exception&) + { + // If parsing fails, return empty map + LOG_DEBUG << "Could not read env_vars from state file: " + << state_file_path.string(); + } + } + + return env_vars; + } + + // Helper function: Extract channel name from channel string (URL or name) + std::string + extract_channel_name(ChannelContext& channel_context, const std::string& channel_str) + { + if (channel_str.empty()) + { + return {}; + } + + // If it's already a simple channel name (no URL), return as-is + if (channel_str.find("://") == std::string::npos) + { + return channel_str; + } + + // Try to resolve the channel URL to get the channel name + try + { + const auto& channels = channel_context.make_channel(channel_str); + if (!channels.empty()) + { + return channels.front().id(); + } + } + catch (...) + { + // If resolution fails, try to extract channel name from URL + // e.g., "https://conda.anaconda.org/conda-forge" -> "conda-forge" + if (channel_str.find("conda.anaconda.org/") != std::string::npos) + { + auto parts = util::rsplit(channel_str, '/'); + if (parts.size() >= 2) + { + return parts[parts.size() - 1]; + } + } + } + + return channel_str; + } + + // Helper function: Extract channels from packages + std::vector extract_channels_from_packages( + const PrefixData& prefix_data, + ChannelContext& channel_context, + bool ignore_channels = false + ) + { + if (ignore_channels) + { + return {}; + } + + std::set channel_set; + std::vector channels; + + const auto records = prefix_data.sorted_records(); + for (const auto& pkg : records) + { + if (!pkg.channel.empty()) + { + std::string channel_name = extract_channel_name(channel_context, pkg.channel); + // Only add if not already seen (maintain order of first appearance) + if (!channel_set.contains(channel_name)) + { + channel_set.insert(channel_name); + channels.push_back(channel_name); + } + } + } + + return channels; + } + + // Helper function: Convert PackageInfo to MatchSpec string + std::string package_to_spec_string( + const specs::PackageInfo& pkg, + ChannelContext& channel_context, + bool no_builds = false, + bool ignore_channels = false, + bool include_md5 = false + ) + { + std::string spec; + + // Add channel prefix if not ignoring channels + if (!ignore_channels && !pkg.channel.empty()) + { + std::string channel_name = extract_channel_name(channel_context, pkg.channel); + spec = channel_name + "::"; + } + + // Add package name + spec += pkg.name; + + // Add version if available + if (!pkg.version.empty()) + { + spec += "=" + pkg.version; + } + + // Add build string if not excluding builds + if (!no_builds && !pkg.build_string.empty()) + { + spec += "=" + pkg.build_string; + } + + // Add md5 if requested (e.g. for env export --md5) + if (include_md5 && !pkg.md5.empty()) + { + spec += "[md5=" + pkg.md5 + "]"; + } + + return spec; + } + + // Helper function: Download file from URL if needed + std::unique_ptr + downloaded_file_from_url(const Context& ctx, const std::string& url_str) + { + if (url_str.find("://") != std::string::npos) + { + LOG_INFO << "Downloading file from " << url_str; + auto url_parts = util::rsplit(url_str, '/'); + std::string filename = (url_parts.size() == 1) ? "" : url_parts.back(); + auto tmp_file = std::make_unique("mambaf", util::concat("_", filename)); + download::Request request( + "Environment lock or yaml file", + download::MirrorName(""), + url_str, + tmp_file->path() + ); + const download::Result res = download::download( + std::move(request), + ctx.mirrors, + ctx.remote_fetch_params, + ctx.authentication_info(), + ctx.download_options() + ); + + if (!res || res.value().transfer.http_status != 200) + { + throw std::runtime_error( + fmt::format("Could not download environment lock or yaml file from {}", url_str) + ); + } + + return tmp_file; + } + return nullptr; + } + } // namespace + + detail::yaml_file_contents prefix_to_yaml_contents( + const PrefixData& prefix_data, + const Context& ctx, + const std::string& env_name, + const PrefixToYamlOptions& options + ) + { + detail::yaml_file_contents result; + + // Create channel context to resolve channel names + auto channel_context = ChannelContext::make_conda_compatible(ctx); + + // Get environment name + if (env_name.empty()) + { + result.name = detail::get_env_name(ctx, prefix_data.path()); + } + else + { + result.name = env_name; + } + + // Set prefix + result.prefix = prefix_data.path().string(); + + // Extract channels from packages + result.channels = extract_channels_from_packages( + prefix_data, + channel_context, + options.ignore_channels + ); + + // Extract dependencies from installed packages + const auto records = prefix_data.sorted_records(); + result.dependencies.reserve(records.size()); + for (const auto& pkg : records) + { + std::string spec = package_to_spec_string( + pkg, + channel_context, + options.no_builds, + options.ignore_channels, + options.include_md5 + ); + result.dependencies.push_back(std::move(spec)); + } + + // Extract pip packages + const auto& pip_records = prefix_data.pip_records(); + if (!pip_records.empty()) + { + detail::other_pkg_mgr_spec pip_spec; + pip_spec.pkg_mgr = "pip"; + pip_spec.cwd = prefix_data.path().string(); + pip_spec.deps.reserve(pip_records.size()); + + for (const auto& [name, pkg] : pip_records) + { + std::string pip_dep = pkg.name; + if (!pkg.version.empty()) + { + pip_dep += "==" + pkg.version; + } + pip_spec.deps.push_back(std::move(pip_dep)); + } + + result.others_pkg_mgrs_specs.push_back(std::move(pip_spec)); + + // Add pip to dependencies if not already present + if (std::find(result.dependencies.begin(), result.dependencies.end(), "pip") + == result.dependencies.end()) + { + result.dependencies.push_back("pip"); + } + } + + // Read environment variables from state file + result.variables = read_env_vars_from_prefix(prefix_data.path()); + + return result; + } + + void yaml_contents_to_stream(const detail::yaml_file_contents& contents, std::ostream& out) + { + YAML::Node root; + + // Add name if present + if (!contents.name.empty()) + { + root["name"] = contents.name; + } + + // Add prefix if present + if (!contents.prefix.empty()) + { + root["prefix"] = contents.prefix; + } + + // Always add channels (empty list when none) for consistent export structure + root["channels"] = contents.channels; + + // Add dependencies + YAML::Node deps_node; + for (const auto& dep : contents.dependencies) + { + deps_node.push_back(dep); + } + + // Add pip/uv dependencies as maps + for (const auto& other_spec : contents.others_pkg_mgrs_specs) + { + if (other_spec.pkg_mgr == "pip" || other_spec.pkg_mgr == "uv") + { + YAML::Node pip_node; + pip_node[other_spec.pkg_mgr] = other_spec.deps; + deps_node.push_back(pip_node); + } + } + + // Always add dependencies (empty list when none) for consistent export structure + root["dependencies"] = deps_node; + + // Add variables if present + if (!contents.variables.empty()) + { + root["variables"] = contents.variables; + } + + // Write to stream + YAML::Emitter emitter; + emitter << root; + out << emitter.c_str() << "\n"; + } + + void + yaml_contents_to_file(const detail::yaml_file_contents& contents, const fs::u8path& yaml_file_path) + { + // Write to file using the stream function + fs::create_directories(yaml_file_path.parent_path()); + std::ofstream out = open_ofstream(yaml_file_path); + if (out.fail()) + { + throw std::runtime_error("Couldn't open file for writing: " + yaml_file_path.string()); + } + + yaml_contents_to_stream(contents, out); + } + + detail::yaml_file_contents file_to_yaml_contents( + const Context& ctx, + const std::string& yaml_file, + const std::string& platform, + bool use_uv + ) + { + // Download content of environment yaml file if URL + auto tmp_yaml_file = downloaded_file_from_url(ctx, yaml_file); + fs::u8path file; + + if (tmp_yaml_file) + { + file = tmp_yaml_file->path(); + } + else + { + file = fs::weakly_canonical(util::expand_home(yaml_file)); + if (!fs::exists(file)) + { + LOG_ERROR << "YAML spec file '" << file.string() << "' not found"; + throw std::runtime_error("File not found. Aborting."); + } + } + + detail::yaml_file_contents result; + YAML::Node f; + try + { + f = YAML::LoadFile(file.string()); + } + catch (YAML::Exception& e) + { + LOG_ERROR << "YAML error in spec file '" << file.string() << "'"; + throw e; + } + + YAML::Node deps; + if (f["dependencies"] && f["dependencies"].IsSequence() && f["dependencies"].size() > 0) + { + deps = f["dependencies"]; + } + else + { + // Empty or absent `dependencies` key + deps = YAML::Node(YAML::NodeType::Null); + } + YAML::Node final_deps; + + bool has_pip_deps = false; + for (auto it = deps.begin(); it != deps.end(); ++it) + { + if (it->IsScalar()) + { + final_deps.push_back(*it); + } + else if (it->IsMap()) + { + // we merge a map to the upper level if the selector works + for (const auto& map_el : *it) + { + std::string key = map_el.first.as(); + if (util::starts_with(key, "sel(")) + { + bool selected = detail::eval_selector(key, platform); + if (selected) + { + const YAML::Node& rest = map_el.second; + if (rest.IsScalar()) + { + final_deps.push_back(rest); + } + else + { + throw std::runtime_error( + "Complicated selection merge not implemented yet." + ); + } + } + } + else if (key == "pip" || key == "uv") + { + std::string yaml_parent_path; + if (tmp_yaml_file) // yaml file is fetched remotely + { + yaml_parent_path = yaml_file; + } + else + { + yaml_parent_path = fs::absolute(yaml_file).parent_path().string(); + } + result.others_pkg_mgrs_specs.push_back( + { + use_uv && key == "pip" ? "uv" : key, + map_el.second.as>(), + yaml_parent_path, + } + ); + has_pip_deps = true; + } + } + } + } + + std::vector dependencies; + try + { + if (final_deps.IsNull()) + { + dependencies = {}; + } + else + { + dependencies = final_deps.as>(); + } + } + catch (const YAML::Exception& e) + { + LOG_ERROR << "Bad conversion of 'dependencies' to a vector of string: " << final_deps; + throw e; + } + + // Check if pip/uv was explicitly in the scalar dependencies from the file + bool has_pip_in_file = std::count(dependencies.begin(), dependencies.end(), "pip") > 0; + bool has_uv_in_file = std::count(dependencies.begin(), dependencies.end(), "uv") > 0; + + if (has_pip_deps && use_uv && !has_uv_in_file) + { + dependencies.push_back("uv"); + } + else if (has_pip_deps && has_uv_in_file) + { + for (auto& spec : result.others_pkg_mgrs_specs) + { + if (spec.pkg_mgr == "pip") + { + spec.pkg_mgr = "uv"; + } + } + } + // Add "pip" to dependencies if pip dependencies exist but "pip" is not in dependencies. + // Do not add "pip" when the file already has "uv" (we use uv for pip deps in that case). + if (has_pip_deps && !has_pip_in_file && !use_uv && !has_uv_in_file) + { + dependencies.push_back("pip"); + } + + result.dependencies = dependencies; + + if (f["channels"]) + { + try + { + result.channels = f["channels"].as>(); + } + catch (YAML::Exception& e) + { + LOG_ERROR << "Could not read 'channels' as vector of strings from '" + << file.string() << "'"; + throw e; + } + } + else + { + LOG_DEBUG << "No 'channels' specified in YAML spec file '" << file.string() << "'"; + } + + if (f["name"]) + { + result.name = f["name"].as(); + } + else + { + LOG_DEBUG << "No env 'name' specified in YAML spec file '" << file.string() << "'"; + } + + if (f["variables"]) + { + result.variables = f["variables"].as>(); + } + else + { + LOG_DEBUG << "No 'variables' specified in YAML spec file '" << file.string() << "'"; + } + + if (f["prefix"]) + { + result.prefix = f["prefix"].as(); + } + else + { + LOG_DEBUG << "No 'prefix' specified in YAML spec file '" << file.string() << "'"; + } + + return result; + } +} diff --git a/libmamba/src/api/export.cpp b/libmamba/src/api/export.cpp new file mode 100644 index 0000000000..75f5f65b99 --- /dev/null +++ b/libmamba/src/api/export.cpp @@ -0,0 +1,81 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. +// +// The full license is in the file LICENSE, distributed with this software. + +#include +#include + +#include "mamba/api/configuration.hpp" +#include "mamba/api/env.hpp" +#include "mamba/api/environment_yaml.hpp" +#include "mamba/api/export.hpp" +#include "mamba/api/install.hpp" +#include "mamba/core/channel_context.hpp" +#include "mamba/core/context.hpp" +#include "mamba/core/output.hpp" +#include "mamba/core/prefix_data.hpp" +#include "mamba/fs/filesystem.hpp" + +namespace mamba +{ + void export_environment(Configuration& config) + { + auto& ctx = config.context(); + config.load(); + + auto& file = config.at("file"); + auto& no_builds = config.at("no_builds").value(); + auto& ignore_channels = config.at("ignore_channels").value(); + + if (ctx.prefix_params.target_prefix.empty()) + { + throw std::runtime_error("No target prefix specified for export"); + } + + auto channel_context = ChannelContext::make_conda_compatible(ctx); + + // Load prefix data + auto maybe_prefix_data = PrefixData::create(ctx.prefix_params.target_prefix, channel_context); + if (!maybe_prefix_data) + { + throw std::runtime_error(maybe_prefix_data.error().what()); + } + PrefixData& prefix_data = maybe_prefix_data.value(); + + // Get environment name + std::string env_name = detail::get_env_name(ctx, ctx.prefix_params.target_prefix); + + // Convert prefix to yaml_file_contents + auto yaml_contents = prefix_to_yaml_contents( + prefix_data, + ctx, + env_name, + { no_builds, ignore_channels, false } + ); + + // Determine output file path + fs::u8path output_file; + if (file.configured()) + { + output_file = fs::u8path(file.value()); + } + else + { + output_file = fs::current_path() / "environment.yaml"; + } + + // Write to file + yaml_contents_to_file(yaml_contents, output_file); + + if (ctx.output_params.json) + { + Console::instance().json_write({ { "success", true }, { "file", output_file.string() } }); + } + else + { + Console::stream() << "Environment exported to: " << output_file.string() << "\n"; + } + } +} diff --git a/libmamba/src/api/install.cpp b/libmamba/src/api/install.cpp index 85711f1a7a..38b3262f94 100644 --- a/libmamba/src/api/install.cpp +++ b/libmamba/src/api/install.cpp @@ -9,6 +9,7 @@ #include "mamba/api/channel_loader.hpp" #include "mamba/api/configuration.hpp" +#include "mamba/api/environment_yaml.hpp" #include "mamba/api/install.hpp" #include "mamba/core/channel_context.hpp" #include "mamba/core/context.hpp" @@ -121,188 +122,6 @@ namespace mamba return nullptr; } - yaml_file_contents read_yaml_file( - const Context& ctx, - const std::string& yaml_file, - const std::string& platform, - bool use_uv - ) - { - // Download content of environment yaml file - auto tmp_yaml_file = downloaded_file_from_url(ctx, yaml_file); - fs::u8path file; - - if (tmp_yaml_file) - { - file = tmp_yaml_file->path(); - } - else - { - file = fs::weakly_canonical(util::expand_home(yaml_file)); - if (!fs::exists(file)) - { - LOG_ERROR << "YAML spec file '" << file.string() << "' not found"; - throw std::runtime_error("File not found. Aborting."); - } - } - - yaml_file_contents result; - YAML::Node f; - try - { - f = YAML::LoadFile(file.string()); - } - catch (YAML::Exception& e) - { - LOG_ERROR << "YAML error in spec file '" << file.string() << "'"; - throw e; - } - - YAML::Node deps; - if (f["dependencies"] && f["dependencies"].IsSequence() && f["dependencies"].size() > 0) - { - deps = f["dependencies"]; - } - else - { - // Empty of absent `dependencies` key - deps = YAML::Node(YAML::NodeType::Null); - } - YAML::Node final_deps; - - bool has_pip_deps = false; - for (auto it = deps.begin(); it != deps.end(); ++it) - { - if (it->IsScalar()) - { - final_deps.push_back(*it); - } - else if (it->IsMap()) - { - // we merge a map to the upper level if the selector works - for (const auto& map_el : *it) - { - std::string key = map_el.first.as(); - if (util::starts_with(key, "sel(")) - { - bool selected = detail::eval_selector(key, platform); - if (selected) - { - const YAML::Node& rest = map_el.second; - if (rest.IsScalar()) - { - final_deps.push_back(rest); - } - else - { - throw std::runtime_error( - "Complicated selection merge not implemented yet." - ); - } - } - } - else if (key == "pip") - { - std::string yaml_parent_path; - if (tmp_yaml_file) // yaml file is fetched remotely - { - yaml_parent_path = yaml_file; - } - else - { - yaml_parent_path = fs::absolute(yaml_file).parent_path().string(); - } - result.others_pkg_mgrs_specs.push_back( - { - use_uv ? "uv" : "pip", - map_el.second.as>(), - yaml_parent_path, - } - ); - has_pip_deps = true; - } - } - } - } - - std::vector dependencies; - try - { - if (final_deps.IsNull()) - { - dependencies = {}; - } - else - { - dependencies = final_deps.as>(); - } - } - catch (const YAML::Exception& e) - { - LOG_ERROR << "Bad conversion of 'dependencies' to a vector of string: " << final_deps; - throw e; - } - - if (has_pip_deps && use_uv && !std::count(dependencies.begin(), dependencies.end(), "uv")) - { - dependencies.push_back("uv"); - } - else if (has_pip_deps && std::count(dependencies.begin(), dependencies.end(), "uv")) - { - for (auto& spec : result.others_pkg_mgrs_specs) - { - if (spec.pkg_mgr == "pip") - { - spec.pkg_mgr = "uv"; - } - } - } - else if (has_pip_deps && !std::count(dependencies.begin(), dependencies.end(), "pip")) - { - dependencies.push_back("pip"); - } - - result.dependencies = dependencies; - - if (f["channels"]) - { - try - { - result.channels = f["channels"].as>(); - } - catch (YAML::Exception& e) - { - LOG_ERROR << "Could not read 'channels' as vector of strings from '" - << file.string() << "'"; - throw e; - } - } - else - { - LOG_DEBUG << "No 'channels' specified in YAML spec file '" << file.string() << "'"; - } - - if (f["name"]) - { - result.name = f["name"].as(); - } - else - { - LOG_DEBUG << "No env 'name' specified in YAML spec file '" << file.string() << "'"; - } - - if (f["variables"]) - { - result.variables = f["variables"].as>(); - } - else - { - LOG_DEBUG << "No 'variables' specified in YAML spec file '" << file.string() << "'"; - } - - return result; - } - bool operator==(const other_pkg_mgr_spec& s1, const other_pkg_mgr_spec& s2) { return (s1.pkg_mgr == s2.pkg_mgr) && (s1.deps == s2.deps) && (s1.cwd == s2.cwd); @@ -1117,7 +936,7 @@ namespace mamba } else if (is_yaml_file_name(file)) { - const auto parse_result = read_yaml_file( + const auto parse_result = file_to_yaml_contents( context, file, context.platform, diff --git a/libmamba/tests/CMakeLists.txt b/libmamba/tests/CMakeLists.txt index e3391f63a7..2806ae03af 100644 --- a/libmamba/tests/CMakeLists.txt +++ b/libmamba/tests/CMakeLists.txt @@ -91,6 +91,7 @@ set( src/core/test_cpp.cpp src/core/test_env_file_reading.cpp src/core/test_env_lockfile.cpp + src/core/test_environment_yaml.cpp src/core/test_environments_manager.cpp src/core/test_execution.cpp src/core/test_filesystem.cpp diff --git a/libmamba/tests/data/env_file/env_3.yaml b/libmamba/tests/data/env_file/env_3.yaml index 2a25e0316c..736625026a 100644 --- a/libmamba/tests/data/env_file/env_3.yaml +++ b/libmamba/tests/data/env_file/env_3.yaml @@ -4,6 +4,7 @@ dependencies: - test1 - test2 - test3 + - pip - pip: - pytest - numpy diff --git a/libmamba/tests/src/core/test_env_file_reading.cpp b/libmamba/tests/src/core/test_env_file_reading.cpp index a3f30e04f8..5a037d1e0a 100644 --- a/libmamba/tests/src/core/test_env_file_reading.cpp +++ b/libmamba/tests/src/core/test_env_file_reading.cpp @@ -6,6 +6,7 @@ #include +#include "mamba/api/environment_yaml.hpp" #include "mamba/api/install.hpp" #include "mamba/util/build.hpp" @@ -47,9 +48,9 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, - mambatests::test_data_dir / "env_file/env_1.yaml", + (mambatests::test_data_dir / "env_file/env_1.yaml").string(), context.platform, false ); @@ -58,9 +59,9 @@ namespace mamba REQUIRE(res.dependencies == V({ "test1", "test2", "test3" })); REQUIRE_FALSE(res.others_pkg_mgrs_specs.size()); - auto res2 = detail::read_yaml_file( + auto res2 = file_to_yaml_contents( context, - mambatests::test_data_dir / "env_file/env_2.yaml", + (mambatests::test_data_dir / "env_file/env_2.yaml").string(), context.platform, false ); @@ -80,9 +81,9 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, - mambatests::test_data_dir / "env_file/env_3.yaml", + (mambatests::test_data_dir / "env_file/env_3.yaml").string(), context.platform, false ); @@ -94,7 +95,7 @@ namespace mamba auto o = res.others_pkg_mgrs_specs[0]; REQUIRE(o.pkg_mgr == "pip"); REQUIRE(o.deps == V({ "pytest", "numpy" })); - REQUIRE(o.cwd == fs::absolute(mambatests::test_data_dir / "env_file")); + REQUIRE(o.cwd == mamba::fs::absolute(mambatests::test_data_dir / "env_file").string()); } TEST_CASE("remote_yaml_file") @@ -103,7 +104,7 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, "https://raw.githubusercontent.com/mamba-org/mamba/refs/heads/main/micromamba/tests/env-create-export.yaml", context.platform, @@ -118,7 +119,7 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, "https://raw.githubusercontent.com/mamba-org/mamba/refs/heads/main/libmamba/tests/data/env_file/env_3.yaml", context.platform, @@ -141,7 +142,7 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, "https://raw.githubusercontent.com/iisakkirotko/mamba/refs/heads/yaml-install-uv/libmamba/tests/data/env_file/env_4.yaml", context.platform, @@ -164,7 +165,7 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, "https://raw.githubusercontent.com/mamba-org/mamba/refs/heads/main/libmamba/tests/data/env_file/env_3.yaml", context.platform, @@ -187,7 +188,7 @@ namespace mamba { const auto& context = mambatests::context(); using V = std::vector; - auto res = detail::read_yaml_file( + auto res = file_to_yaml_contents( context, "https://raw.githubusercontent.com/mamba-org/mamba/refs/heads/main/libmamba/tests/data/env_file/env_2.yaml", context.platform, diff --git a/libmamba/tests/src/core/test_environment_yaml.cpp b/libmamba/tests/src/core/test_environment_yaml.cpp new file mode 100644 index 0000000000..35af7e26a8 --- /dev/null +++ b/libmamba/tests/src/core/test_environment_yaml.cpp @@ -0,0 +1,485 @@ +// Copyright (c) 2026, QuantStack and Mamba Contributors +// +// Distributed under the terms of the BSD 3-Clause License. +// +// The full license is in the file LICENSE, distributed with this software. + +#include +#include +#include + +#include + +#include "mamba/api/environment_yaml.hpp" +#include "mamba/api/install.hpp" +#include "mamba/core/channel_context.hpp" +#include "mamba/core/context.hpp" +#include "mamba/core/prefix_data.hpp" +#include "mamba/core/util.hpp" +#include "mamba/specs/package_info.hpp" + +#include "mambatests.hpp" + +namespace mamba +{ + namespace + { + TEST_CASE("file_to_yaml_contents: basic reading", "[api][environment-yaml]") + { + const auto& context = mambatests::context(); + using V = std::vector; + + auto res = file_to_yaml_contents( + context, + (mambatests::test_data_dir / "env_file/env_1.yaml").string(), + context.platform, + false + ); + + REQUIRE(res.name == "env_1"); + REQUIRE(res.channels == V({ "conda-forge", "bioconda" })); + REQUIRE(res.dependencies == V({ "test1", "test2", "test3" })); + REQUIRE(res.others_pkg_mgrs_specs.empty()); + REQUIRE(res.variables.empty()); + } + + TEST_CASE("file_to_yaml_contents: with pip dependencies", "[api][environment-yaml]") + { + const auto& context = mambatests::context(); + using V = std::vector; + + auto res = file_to_yaml_contents( + context, + (mambatests::test_data_dir / "env_file/env_3.yaml").string(), + context.platform, + false + ); + + REQUIRE(res.name == "env_3"); + REQUIRE(res.channels == V({ "conda-forge", "bioconda" })); + REQUIRE(res.dependencies == V({ "test1", "test2", "test3", "pip" })); + + REQUIRE(res.others_pkg_mgrs_specs.size() == 1); + REQUIRE(res.others_pkg_mgrs_specs[0].pkg_mgr == "pip"); + REQUIRE(res.others_pkg_mgrs_specs[0].deps == V({ "pytest", "numpy" })); + } + + TEST_CASE("file_to_yaml_contents: with variables", "[api][environment-yaml]") + { + const auto& context = mambatests::context(); + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "test_env.yaml"; + + { + std::ofstream out(yaml_file.string()); + out << "name: test_env\n"; + out << "channels:\n"; + out << " - conda-forge\n"; + out << "dependencies:\n"; + out << " - python=3.10\n"; + out << "variables:\n"; + out << " test_var: test_value\n"; + out << " another_var: another_value\n"; + } + + auto res = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + REQUIRE(res.name == "test_env"); + REQUIRE(res.variables.size() == 2); + REQUIRE(res.variables.at("test_var") == "test_value"); + REQUIRE(res.variables.at("another_var") == "another_value"); + } + + TEST_CASE("yaml_contents_to_file: basic writing", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "output.yaml"; + + detail::yaml_file_contents contents; + contents.name = "test_env"; + contents.channels = { "conda-forge", "bioconda" }; + contents.dependencies = { "python=3.10", "numpy" }; + + yaml_contents_to_file(contents, yaml_file); + + REQUIRE(fs::exists(yaml_file)); + + // Read it back and verify + const auto& context = mambatests::context(); + auto read_back = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + REQUIRE(read_back.name == contents.name); + REQUIRE(read_back.channels == contents.channels); + REQUIRE(read_back.dependencies == contents.dependencies); + } + + TEST_CASE("yaml_contents_to_file: with pip dependencies", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "output.yaml"; + + detail::yaml_file_contents contents; + contents.name = "test_env"; + contents.channels = { "conda-forge" }; + contents.dependencies = { "python=3.10", "pip" }; + contents.others_pkg_mgrs_specs.push_back( + { "pip", { "pytest", "numpy" }, tmp_dir.path().string() } + ); + + yaml_contents_to_file(contents, yaml_file); + + REQUIRE(fs::exists(yaml_file)); + + // Read it back and verify + const auto& context = mambatests::context(); + auto read_back = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + REQUIRE(read_back.name == contents.name); + REQUIRE(read_back.channels == contents.channels); + REQUIRE(read_back.dependencies == contents.dependencies); + REQUIRE(read_back.others_pkg_mgrs_specs.size() == 1); + REQUIRE(read_back.others_pkg_mgrs_specs[0].pkg_mgr == "pip"); + REQUIRE(read_back.others_pkg_mgrs_specs[0].deps == contents.others_pkg_mgrs_specs[0].deps); + } + + TEST_CASE("yaml_contents_to_file: with variables", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "output.yaml"; + + detail::yaml_file_contents contents; + contents.name = "test_env"; + contents.channels = { "conda-forge" }; + contents.dependencies = { "python=3.10" }; + contents.variables = { { "test_var", "test_value" }, { "another_var", "another_value" } }; + + yaml_contents_to_file(contents, yaml_file); + + REQUIRE(fs::exists(yaml_file)); + + // Read it back and verify + const auto& context = mambatests::context(); + auto read_back = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + REQUIRE(read_back.variables.size() == 2); + REQUIRE(read_back.variables.at("test_var") == "test_value"); + REQUIRE(read_back.variables.at("another_var") == "another_value"); + } + + TEST_CASE("prefix_to_yaml_contents: basic conversion", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + // Create a minimal conda environment structure + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + // Create package records + auto python_pkg_json = conda_meta_dir / "python-3.10.0-h12345_0.json"; + { + auto out = open_ofstream(python_pkg_json); + out << R"({ + "name": "python", + "version": "3.10.0", + "build_string": "h12345_0", + "build_number": 0, + "channel": "conda-forge", + "platform": "linux-64", + "package_url": "file:///path/to/python-3.10.0-h12345_0.tar.bz2" + })"; + } + + auto numpy_pkg_json = conda_meta_dir / "numpy-1.24.0-py310h12345_0.json"; + { + auto out = open_ofstream(numpy_pkg_json); + out << R"({ + "name": "numpy", + "version": "1.24.0", + "build_string": "py310h12345_0", + "build_number": 0, + "channel": "conda-forge", + "platform": "linux-64", + "package_url": "file:///path/to/numpy-1.24.0-py310h12345_0.tar.bz2" + })"; + } + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + auto yaml_contents = prefix_to_yaml_contents(prefix_data, ctx, "test_env"); + + REQUIRE(yaml_contents.name == "test_env"); + REQUIRE(yaml_contents.channels.size() == 1); + REQUIRE(yaml_contents.channels[0] == "conda-forge"); + REQUIRE(yaml_contents.dependencies.size() == 2); + // Dependencies should contain python and numpy with channel prefix + bool has_python = false; + bool has_numpy = false; + for (const auto& dep : yaml_contents.dependencies) + { + if (dep.find("python") != std::string::npos && dep.find("3.10.0") != std::string::npos) + { + has_python = true; + } + if (dep.find("numpy") != std::string::npos && dep.find("1.24.0") != std::string::npos) + { + has_numpy = true; + } + } + REQUIRE(has_python); + REQUIRE(has_numpy); + } + + TEST_CASE("prefix_to_yaml_contents: with environment variables", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + // Create conda-meta directory + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + // Create state file with environment variables (UPPERCASE keys) + auto state_file = conda_meta_dir / "state"; + { + auto out = open_ofstream(state_file); + out << R"({ + "env_vars": { + "TEST_VAR": "test_value", + "ANOTHER_VAR": "another_value" + } + })"; + } + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + auto yaml_contents = prefix_to_yaml_contents(prefix_data, ctx, ""); + + // Variables should be converted to lowercase + REQUIRE(yaml_contents.variables.size() == 2); + REQUIRE(yaml_contents.variables.at("test_var") == "test_value"); + REQUIRE(yaml_contents.variables.at("another_var") == "another_value"); + } + + TEST_CASE("prefix_to_yaml_contents: no_builds option", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + auto python_pkg_json = conda_meta_dir / "python-3.10.0-h12345_0.json"; + { + auto out = open_ofstream(python_pkg_json); + out << R"({ + "name": "python", + "version": "3.10.0", + "build_string": "h12345_0", + "build_number": 0, + "channel": "conda-forge", + "platform": "linux-64", + "package_url": "file:///path/to/python-3.10.0-h12345_0.tar.bz2" + })"; + } + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + auto yaml_contents = prefix_to_yaml_contents(prefix_data, ctx, "", { .no_builds = true }); + + // With no_builds=true, build string should not be included + REQUIRE(yaml_contents.dependencies.size() == 1); + REQUIRE( + std::find( + yaml_contents.dependencies.begin(), + yaml_contents.dependencies.end(), + "conda-forge::python=3.10.0" + ) + != yaml_contents.dependencies.end() + ); + } + + TEST_CASE("prefix_to_yaml_contents: ignore_channels option", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + auto python_pkg_json = conda_meta_dir / "python-3.10.0-h12345_0.json"; + { + auto out = open_ofstream(python_pkg_json); + out << R"({ + "name": "python", + "version": "3.10.0", + "build_string": "h12345_0", + "build_number": 0, + "channel": "conda-forge", + "platform": "linux-64", + "package_url": "file:///path/to/python-3.10.0-h12345_0.tar.bz2" + })"; + } + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + auto yaml_contents = prefix_to_yaml_contents( + prefix_data, + ctx, + "", + { .ignore_channels = true } + ); + + // With ignore_channels=true, channels should be empty and dependencies should not have + // channel prefix + REQUIRE(yaml_contents.channels.empty()); + REQUIRE(yaml_contents.dependencies.size() == 1); + bool has_python_no_channel = false; + for (const auto& dep : yaml_contents.dependencies) + { + if (dep.find("python") != std::string::npos && dep.find("3.10.0") != std::string::npos + && dep.find("conda-forge::") == std::string::npos) + { + has_python_no_channel = true; + } + } + REQUIRE(has_python_no_channel); + } + + TEST_CASE("round_trip: yaml_file_contents to file and back", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "roundtrip.yaml"; + + detail::yaml_file_contents original; + original.name = "roundtrip_test"; + original.channels = { "conda-forge", "bioconda" }; + // Include "pip" in dependencies when pip dependencies exist (standard conda format) + original.dependencies = { "python=3.10", "numpy", "pandas", "pip" }; + original.variables = { { "var1", "value1" }, { "var2", "value2" } }; + original.others_pkg_mgrs_specs.push_back( + { "pip", { "pytest", "black" }, tmp_dir.path().string() } + ); + + // Write to file + yaml_contents_to_file(original, yaml_file); + + // Read back + const auto& context = mambatests::context(); + auto read_back = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + // Verify all fields match + REQUIRE(read_back.name == original.name); + REQUIRE(read_back.channels == original.channels); + REQUIRE(read_back.dependencies == original.dependencies); + REQUIRE(read_back.variables == original.variables); + REQUIRE(read_back.others_pkg_mgrs_specs.size() == original.others_pkg_mgrs_specs.size()); + if (!read_back.others_pkg_mgrs_specs.empty()) + { + REQUIRE( + read_back.others_pkg_mgrs_specs[0].pkg_mgr + == original.others_pkg_mgrs_specs[0].pkg_mgr + ); + REQUIRE( + read_back.others_pkg_mgrs_specs[0].deps == original.others_pkg_mgrs_specs[0].deps + ); + } + } + + TEST_CASE("environment_variables: case conversion round-trip", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + // Create conda-meta directory + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + // Create state file with UPPERCASE keys + auto state_file = conda_meta_dir / "state"; + { + auto out = open_ofstream(state_file); + out << R"({ + "env_vars": { + "MY_VAR": "my_value", + "ANOTHER_VAR": "another_value" + } + })"; + } + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + // Export: prefix → yaml_file_contents (should convert UPPERCASE to lowercase) + auto yaml_contents = prefix_to_yaml_contents(prefix_data, ctx, ""); + REQUIRE(yaml_contents.variables.at("my_var") == "my_value"); + REQUIRE(yaml_contents.variables.at("another_var") == "another_value"); + + // Write to YAML file + auto yaml_file = tmp_dir.path() / "env.yaml"; + yaml_contents_to_file(yaml_contents, yaml_file); + + // Read back from YAML (should have lowercase keys) + auto read_back = file_to_yaml_contents(ctx, yaml_file.string(), ctx.platform, false); + REQUIRE(read_back.variables.at("my_var") == "my_value"); + REQUIRE(read_back.variables.at("another_var") == "another_value"); + } + + TEST_CASE("environment_variables: missing state file", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto prefix_path = tmp_dir.path() / "prefix"; + fs::create_directories(prefix_path); + + auto& ctx = mambatests::context(); + auto channel_context = ChannelContext::make_simple(ctx); + + // Create conda-meta directory but no state file + auto conda_meta_dir = prefix_path / "conda-meta"; + fs::create_directories(conda_meta_dir); + + auto prefix_data = PrefixData::create(prefix_path, channel_context, true).value(); + + // Should not crash, should return empty variables + auto yaml_contents = prefix_to_yaml_contents(prefix_data, ctx, ""); + REQUIRE(yaml_contents.variables.empty()); + } + + TEST_CASE("environment_variables: empty variables section", "[api][environment-yaml]") + { + auto tmp_dir = TemporaryDirectory(); + auto yaml_file = tmp_dir.path() / "test.yaml"; + + detail::yaml_file_contents contents; + contents.name = "test"; + contents.dependencies = { "python" }; + // No variables set + + yaml_contents_to_file(contents, yaml_file); + + const auto& context = mambatests::context(); + auto read_back = file_to_yaml_contents(context, yaml_file.string(), context.platform, false); + + REQUIRE(read_back.variables.empty()); + } + } +} diff --git a/micromamba/src/env.cpp b/micromamba/src/env.cpp index a91a99074d..e52258e2a8 100644 --- a/micromamba/src/env.cpp +++ b/micromamba/src/env.cpp @@ -4,15 +4,23 @@ // // The full license is in the file LICENSE, distributed with this software. +#include +#include +#include #include +#include + #include "mamba/api/configuration.hpp" #include "mamba/api/create.hpp" #include "mamba/api/env.hpp" +#include "mamba/api/environment_yaml.hpp" +#include "mamba/api/install.hpp" #include "mamba/api/remove.hpp" #include "mamba/api/update.hpp" #include "mamba/core/channel_context.hpp" #include "mamba/core/environments_manager.hpp" +#include "mamba/core/history.hpp" #include "mamba/core/prefix_data.hpp" #include "mamba/core/util.hpp" #include "mamba/specs/conda_url.hpp" @@ -215,79 +223,161 @@ set_env_command(CLI::App* com, mamba::Configuration& config) } else { + // Use the new modular API for YAML export auto pd = mamba::PrefixData::create(ctx.prefix_params.target_prefix, channel_context) .value(); - mamba::History& hist = pd.history(); - - const auto& versions_map = pd.records(); - const auto& pip_versions_map = pd.pip_records(); - - std::cout << "name: " - << mamba::detail::get_env_name(ctx, ctx.prefix_params.target_prefix) - << "\n"; - std::cout << "channels:\n"; - auto requested_specs_map = hist.get_requested_specs_map(); - std::stringstream dependencies; - std::set channels; + mamba::detail::yaml_file_contents yaml_contents; - for (const auto& [k, v] : versions_map) + // Handle --from-history: use requested specs from history + if (from_history) { - if (from_history && requested_specs_map.find(k) == requested_specs_map.end()) - { - continue; - } + mamba::History& hist = pd.history(); + auto requested_specs_map = hist.get_requested_specs_map(); + const auto& versions_map = pd.records(); - auto chans = channel_context.make_channel(v.channel); + yaml_contents.name = mamba::detail::get_env_name( + ctx, + ctx.prefix_params.target_prefix + ); + yaml_contents.prefix = ctx.prefix_params.target_prefix.string(); - if (from_history) - { - dependencies << " - " << requested_specs_map[k].to_string() << "\n"; - } - else + // Build dependencies from requested specs + std::set channels_set; + for (const auto& [k, v] : versions_map) { - dependencies << " - "; - if (channel_subdir) + if (requested_specs_map.find(k) != requested_specs_map.end()) { - dependencies - // If the size is not one, it's a custom multi channel - << ((chans.size() == 1) ? chans.front().display_name() : v.channel) - << "/" << v.platform << "::"; + std::string dep = requested_specs_map[k].to_string(); + if (no_md5 == -1 && !v.md5.empty()) + { + dep += "[md5=" + v.md5 + "]"; + } + yaml_contents.dependencies.push_back(std::move(dep)); + auto chans = channel_context.make_channel(v.channel); + for (const auto& chan : chans) + { + channels_set.insert(chan.display_name()); + } } - dependencies << v.name << "=" << v.version; - if (!no_build) + } + yaml_contents.channels.assign(channels_set.begin(), channels_set.end()); + + // Handle pip packages + const auto& pip_versions_map = pd.pip_records(); + if (!pip_versions_map.empty()) + { + mamba::detail::other_pkg_mgr_spec pip_spec; + pip_spec.pkg_mgr = "pip"; + pip_spec.cwd = ctx.prefix_params.target_prefix.string(); + for (const auto& [name, pkg] : pip_versions_map) { - dependencies << "=" << v.build_string; + pip_spec.deps.push_back(pkg.name + "==" + pkg.version); } - if (no_md5 == -1) + yaml_contents.others_pkg_mgrs_specs.push_back(std::move(pip_spec)); + if (std::find( + yaml_contents.dependencies.begin(), + yaml_contents.dependencies.end(), + "pip" + ) + == yaml_contents.dependencies.end()) { - dependencies << "[md5=" << v.md5 << "]"; + yaml_contents.dependencies.push_back("pip"); } - dependencies << "\n"; } - for (const auto& chan : chans) + // Get environment variables - read directly from state file + // (reusing the logic from the new API) + const auto state_file_path = ctx.prefix_params.target_prefix / "conda-meta" + / "state"; + if (mamba::fs::exists(state_file_path)) { - channels.insert(chan.display_name()); + auto fin = mamba::open_ifstream(state_file_path); + try + { + nlohmann::json j; + fin >> j; + if (j.contains("env_vars") && j["env_vars"].is_object()) + { + for (auto it = j["env_vars"].begin(); it != j["env_vars"].end(); ++it) + { + // Convert UPPERCASE keys to lowercase + std::string key = it.key(); + std::string lower_key = mamba::util::to_lower(key); + yaml_contents.variables[lower_key] = it.value().get(); + } + } + } + catch (nlohmann::json::exception&) + { + // If parsing fails, leave variables empty + } } } - // Add a `pip` subsection in `dependencies` listing wheels installed from PyPI - if (!pip_versions_map.empty()) + else { - dependencies << " - pip:\n"; - for (const auto& [k, v] : pip_versions_map) + // Use the new API for standard export + yaml_contents = mamba::prefix_to_yaml_contents( + pd, + ctx, + mamba::detail::get_env_name(ctx, ctx.prefix_params.target_prefix), + { no_build, false, no_md5 == -1 } + ); + + // Handle --channel-subdir: modify dependency strings to include platform + if (channel_subdir) { - dependencies << " - " << v.name << "==" << v.version << "\n"; + const auto& versions_map = pd.records(); + std::vector modified_deps; + modified_deps.reserve(yaml_contents.dependencies.size()); + for (const auto& dep : yaml_contents.dependencies) + { + // Check if this dependency corresponds to a package we can look up + bool modified = false; + for (const auto& [k, v] : versions_map) + { + // Check if dependency string contains this package name + std::string dep_name = v.name + "="; + if (dep.find(dep_name) == 0 + || dep.find("::" + dep_name) != std::string::npos + || dep == v.name) + { + auto chans = channel_context.make_channel(v.channel); + std::string new_dep; + if (chans.size() == 1) + { + new_dep = chans.front().display_name() + "/" + v.platform + + "::"; + } + else + { + new_dep = v.channel + "/" + v.platform + "::"; + } + + // Extract spec part (name=version[=build]) from original dep + auto colon_pos = dep.find("::"); + std::string spec_part = (colon_pos != std::string::npos) + ? dep.substr(colon_pos + 2) + : dep; + new_dep += spec_part; + modified_deps.push_back(new_dep); + modified = true; + break; + } + } + if (!modified) + { + modified_deps.push_back(dep); + } + } + yaml_contents.dependencies = std::move(modified_deps); } } - for (const auto& c : channels) - { - std::cout << " - " << c << "\n"; - } - std::cout << "dependencies:\n" << dependencies.str() << std::endl; - - std::cout << "prefix: " << ctx.prefix_params.target_prefix << std::endl; + // Write YAML directly to stdout + // Note: prefix will be included if it was set (either from prefix_to_yaml_contents + // or in the --from-history case), otherwise it will be omitted + mamba::yaml_contents_to_stream(yaml_contents, std::cout); std::cout.flush(); }