Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add pypi pip requirements export #2049

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
25 changes: 25 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,31 @@ pixi project export conda_explicit_spec output
pixi project export conda_explicit_spec -e default -e test -p linux-64 output
```

### `project export pypi_requirements`

Exports a [requirements.txt](https://pip.pypa.io/en/stable/reference/requirements-file-format/) for a project's pypi dependencies. Entries in the `requirements.txt`
are based on the urls found in the `pixi.lock`. As pixi environments always contain conda packages, which are not exported by this command,
these files do not represent the complete environment. The resulting files can be installed into either a conda environment or a venv using `pip` or `uv`.

```shell
pip install -r <requirements file>
```

##### Arguments

1. `<OUTPUT_DIR>`: Output directory for `requirements.txt` files.

##### Options

- `--environment <ENVIRONMENT> (-e)`: Environment to render. Can be repeated for multiple envs. Defaults to all environments.
- `--platform <PLATFORM> (-p)`: The platform to render. Can be repeated for multiple platforms. Defaults to all platforms available for selected environments.
- `--ignore-pypi-errors`: PyPI dependencies are not supported in the conda explicit spec file. This flag allows creating the spec file even if PyPI dependencies are present.
- `--split-reqs-no-hashes`: Create a separate requirements.txt for dependencies that do not have an associated hash
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand this flag properly. It's default true, but that means setting it will create a false? which would not split the reqs right? If this thought is correct I would rather name it --no-split or something similar.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it couldn't the users also want to just ignore the hashes? e.g. --no-hash? This would then also not require the split.


```sh
pixi project export pypi_requirements output
pixi project export pypi_requirements -e default -e test -p linux-64 output
```

### `project platform add`

Expand Down
7 changes: 7 additions & 0 deletions src/cli/project/export/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::PathBuf;
pub mod conda_environment;
pub mod conda_explicit_spec;
pub mod pypi_requirements;

use crate::Project;
use clap::Parser;
Expand All @@ -21,15 +22,21 @@ pub enum Command {
/// Export project environment to a conda explicit specification file
#[clap(visible_alias = "ces")]
CondaExplicitSpec(conda_explicit_spec::Args),

/// Export project environment to a conda environment.yaml file
CondaEnvironment(conda_environment::Args),

/// Export pypi dependencies of project environment to a pip requirements file
#[clap(visible_alias = "pr")]
PypiRequirements(pypi_requirements::Args),
}

pub async fn execute(args: Args) -> miette::Result<()> {
let project = Project::load_or_else_discover(args.manifest_path.as_deref())?;
match args.command {
Command::CondaExplicitSpec(args) => conda_explicit_spec::execute(project, args).await?,
Command::CondaEnvironment(args) => conda_environment::execute(project, args).await?,
Command::PypiRequirements(args) => pypi_requirements::execute(project, args).await?,
};
Ok(())
}
323 changes: 323 additions & 0 deletions src/cli/project/export/pypi_requirements.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use clap::Parser;
use miette::{Context, IntoDiagnostic};

use crate::cli::cli_config::PrefixUpdateConfig;
use crate::lock_file::UpdateLockFileOptions;
use crate::Project;
use rattler_conda_types::Platform;
use rattler_lock::{Environment, Package, PackageHashes, PypiPackage, PypiPackageData, UrlOrPath};

#[derive(Debug, Parser)]
#[clap(arg_required_else_help = false)]
pub struct Args {
/// Output directory for rendered requirements files
pub output_dir: PathBuf,

/// Environment to render. Can be repeated for multiple envs. Defaults to all environments
#[arg(short, long)]
pub environment: Option<Vec<String>>,

/// The platform to render. Can be repeated for multiple platforms.
/// Defaults to all platforms available for selected environments.
#[arg(short, long)]
pub platform: Option<Vec<Platform>>,

/// Create a separate requirements.txt for dependencies that do not have an associated hash
#[arg(long, default_value = "true")]
pub split_reqs_no_hashes: bool,

#[clap(flatten)]
pub prefix_update_config: PrefixUpdateConfig,
}

#[derive(Debug)]
struct PypiPackageReqData {
source: String,
hash_flag: Option<String>,
editable: bool,
}

impl PypiPackageReqData {
fn from_pypi_package(p: &PypiPackage) -> Self {
// pip --verify-hashes does not accept hashes for local files
let (s, include_hash) = match p.url() {
UrlOrPath::Url(url) => (url.as_str(), true),
UrlOrPath::Path(path) => (
path.as_os_str()
.to_str()
.unwrap_or_else(|| panic!("Could not convert {:?} to str", path)),
false,
),
};
//
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//

// remove "direct+ since not valid for pip urls"
let s = s.trim_start_matches("direct+");

let hash_flag = if include_hash {
get_pypi_hash_str(p.data().package)
} else {
None
};

Self {
source: s.to_string(),
hash_flag,
editable: p.is_editable(),
}
}

fn to_req_entry(&self) -> String {
let mut entry = String::new();

if self.editable {
entry.push_str("-e ");
}
entry.push_str(&self.source);

if let Some(hash) = &self.hash_flag {
entry.push_str(&format!(" {}", hash));
}

entry
}
}

fn get_pypi_hash_str(package_data: &PypiPackageData) -> Option<String> {
if let Some(hashes) = &package_data.hash {
let h = match hashes {
PackageHashes::Sha256(h) => format!("--hash=sha256:{:x}", h).to_string(),
PackageHashes::Md5Sha256(_, h) => format!("--hash=sha256:{:x}", h).to_string(),
PackageHashes::Md5(h) => format!("--hash=md5:{:x}", h).to_string(),
};
Some(h)
} else {
None
}
}

fn render_pypi_requirements(
target: impl AsRef<Path>,
packages: &[PypiPackageReqData],
) -> miette::Result<()> {
if packages.is_empty() {
return Ok(());
}

let target = target.as_ref();

let reqs = packages
.iter()
.map(|p| p.to_req_entry())
.collect::<Vec<_>>()
.join("\n");

fs::write(target, reqs)
.into_diagnostic()
.with_context(|| format!("failed to write requirements file: {}", target.display()))?;

Ok(())
}

fn render_env_platform(
output_dir: &Path,
env_name: &str,
env: &Environment,
platform: &Platform,
split_nohash: bool,
) -> miette::Result<()> {
let packages = env.packages(*platform).ok_or(miette::miette!(
"platform '{platform}' not found for env {}",
env_name,
))?;

let mut pypi_packages: Vec<PypiPackageReqData> = Vec::new();

for package in packages {
match package {
Package::Pypi(p) => pypi_packages.push(PypiPackageReqData::from_pypi_package(&p)),
Package::Conda(cp) => {
tracing::warn!(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this warning is required as it is kind of intended. We should make it clear in the help message and documentation.

"ignoring Conda package {} since Conda packages are not supported in requirements.txt",
cp.package_record().name.as_normalized()
);
}
}
}

let (base, nohash) = if split_nohash {
// Split package list based on presence of hash since pip currently treats requirements files
// containing any hashes as if `--require-hashes` has been supplied. The only known workaround
// is to split the dependencies, which are typically from vcs sources into a separate
// requirements.txt and to install it separately.
Comment on lines +152 to +155
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to read this part to understand the flag. Could you incorporate this information in the documentation and help of the flag?

pypi_packages
.into_iter()
.partition(|p| p.editable || p.hash_flag.is_some())
} else {
(pypi_packages, vec![])
};

tracing::info!("Creating requirements file for env: {env_name} platform: {platform}");
let target = output_dir
.join(format!("{}_{}_requirements.txt", env_name, platform))
.into_os_string();

render_pypi_requirements(target, &base)?;

if !nohash.is_empty() {
tracing::info!(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Info is only shown on --verbose is that what you want? If it should show on normal level you can either print it with eprintln or with tracing::warn!

"Creating secondary requirements file for env: {env_name} platform: {platform} \
containing packages without hashes. This file will have to be installed separately."
);
let target = output_dir
.join(format!("{}_{}_requirements_nohash.txt", env_name, platform))
.into_os_string();

render_pypi_requirements(target, &nohash)?;
}

Ok(())
}

pub async fn execute(project: Project, args: Args) -> miette::Result<()> {
let lockfile = project
.update_lock_file(UpdateLockFileOptions {
lock_file_usage: args.prefix_update_config.lock_file_usage(),
no_install: args.prefix_update_config.no_install,
..UpdateLockFileOptions::default()
})
.await?
.lock_file;

let mut environments = Vec::new();
if let Some(env_names) = args.environment {
for env_name in &env_names {
environments.push((
env_name.to_string(),
lockfile
.environment(env_name)
.ok_or(miette::miette!("unknown environment {}", env_name))?,
));
}
} else {
for (env_name, env) in lockfile.environments() {
environments.push((env_name.to_string(), env));
}
};

let mut env_platform = Vec::new();

for (env_name, env) in environments {
let available_platforms: HashSet<Platform> = HashSet::from_iter(env.platforms());

if let Some(ref platforms) = args.platform {
for plat in platforms {
if available_platforms.contains(plat) {
env_platform.push((env_name.clone(), env.clone(), *plat));
} else {
tracing::warn!(
"Platform {} not available for environment {}. Skipping...",
plat,
env_name,
);
}
}
} else {
for plat in available_platforms {
env_platform.push((env_name.clone(), env.clone(), plat));
}
}
}

fs::create_dir_all(&args.output_dir).ok();

for (env_name, env, plat) in env_platform {
render_env_platform(
&args.output_dir,
&env_name,
&env,
&plat,
args.split_reqs_no_hashes,
)?;
}

Ok(())
}

#[cfg(test)]
mod tests {
use std::path::Path;

use super::*;
use rattler_lock::LockFile;
use tempfile::tempdir;

#[test]
fn test_render_pypi_requirements() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/cli/project/export/test-data/testenv-pypi/pixi.lock");
let lockfile = LockFile::from_path(&path).unwrap();

let output_dir = tempdir().unwrap();

for (env_name, env) in lockfile.environments() {
for platform in env.platforms() {
render_env_platform(output_dir.path(), env_name, &env, &platform, true).unwrap();

let file_path = output_dir
.path()
.join(format!("{}_{}_requirements.txt", env_name, platform));
insta::assert_snapshot!(
format!("test_render_pypi_requirements_{}_{}", env_name, platform),
fs::read_to_string(file_path).unwrap()
);

let file_path = output_dir
.path()
.join(format!("{}_{}_requirements_nohash.txt", env_name, platform));
insta::assert_snapshot!(
format!(
"test_render_pypi_requirements_nohash_{}_{}",
env_name, platform
),
fs::read_to_string(file_path).unwrap()
);
}
}
}

#[test]
fn test_render_pypi_requirements_nosplit() {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("src/cli/project/export/test-data/testenv-pypi/pixi.lock");
let lockfile = LockFile::from_path(&path).unwrap();

let output_dir = tempdir().unwrap();

for (env_name, env) in lockfile.environments() {
for platform in env.platforms() {
render_env_platform(output_dir.path(), env_name, &env, &platform, false).unwrap();

let file_path = output_dir
.path()
.join(format!("{}_{}_requirements.txt", env_name, platform));
insta::assert_snapshot!(
format!(
"test_render_pypi_requirements_nosplit_{}_{}",
env_name, platform
),
fs::read_to_string(file_path).unwrap()
);

// Check to make sure no "nohash" file is created
let file_path = output_dir
.path()
.join(format!("{}_{}_requirements_nohash.txt", env_name, platform));
assert!(!file_path.exists());
}
}
}
}
Loading
Loading