Skip to content

Commit

Permalink
feat: add --clean-env flag to tasks and run command (prefix-dev#1395)
Browse files Browse the repository at this point in the history
Fixes prefix-dev#289 slightly

Adds:
```
pixi run --clean-env xxx
```

```toml
[tasks]
isolated-task = {cmd = "/usr/bin/env", clean-env = true}
```

This doesn't work on Windows, as it is just to shitty to get working on
windows.
You need to many variables and paths for a working environment for the
label "clean" as you need the `Program Files` directories for the
compilers. And other stuff.

And I'm honestly a 120% Done with this PR. And it's breaking into
actually being productive.
  • Loading branch information
ruben-arts authored and jjjermiah committed Jun 11, 2024
1 parent d681886 commit 818d2ec
Show file tree
Hide file tree
Showing 20 changed files with 979 additions and 598 deletions.
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions docs/features/advanced_tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,23 @@ These variables are not shared over tasks, so you need to define these for every
```
This will output `/tmp/path:/usr/bin:/bin` instead of the original `/usr/bin:/bin`.

## Clean environment
You can make sure the environment of a task is "pixi only".
Here pixi will only include the minimal required environment variables for your platform to run the command in.
The environment will contain all variables set by the conda environment like `"CONDA_PREFIX"`.
It will however include some default values from the shell, like:
`"DISPLAY"`, `"LC_ALL"`, `"LC_TIME"`, `"LC_NUMERIC"`, `"LC_MEASUREMENT"`, `"SHELL"`, `"USER"`, `"USERNAME"`, `"LOGNAME"`, `"HOME"`, `"HOSTNAME"`,`"TMPDIR"`, `"XPC_SERVICE_NAME"`, `"XPC_FLAGS"`

```toml
[tasks]
clean_command = { cmd = "python run_in_isolated_env.py", clean-env = true}
```
This setting can also be set from the command line with `pixi run --clean-env TASK_NAME`.

!!! warning "`clean-env` not supported on Windows"
On Windows it's hard to create a "clean environment" as `conda-forge` doesn't ship Windows compilers and Windows needs a lot of base variables.
Making this feature not worthy of implementing as the amount of edge cases will make it unusable.

## Our task runner: deno_task_shell

To support the different OS's (Windows, OSX and Linux), pixi integrates a shell that can run on all of them.
Expand Down
7 changes: 6 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ You cannot run `pixi run source setup.bash` as `source` is not available in the
- `--frozen`: install the environment as defined in the lock file, doesn't update `pixi.lock` if it isn't up-to-date with [manifest file](project_configuration.md). It can also be controlled by the `PIXI_FROZEN` environment variable (example: `PIXI_FROZEN=true`).
- `--locked`: only install if the `pixi.lock` is up-to-date with the [manifest file](project_configuration.md)[^1]. It can also be controlled by the `PIXI_LOCKED` environment variable (example: `PIXI_LOCKED=true`). Conflicts with `--frozen`.
- `--environment <ENVIRONMENT> (-e)`: The environment to run the task in, if none are provided the default environment will be used or a selector will be given to select the right environment.

- `--clean-env`: Run the task in a clean environment, this will remove all environment variables of the shell environment except for the ones pixi sets. THIS DOESN't WORK ON `Windows`.
```shell
pixi run python
pixi run cowpy "Hey pixi user"
Expand All @@ -193,6 +193,11 @@ pixi run task argument1 argument2

# If you have multiple environments you can select the right one with the --environment flag.
pixi run --environment cuda python

# THIS DOESN'T WORK ON WINDOWS
# If you want to run a command in a clean environment you can use the --clean-env flag.
# The PATH should only contain the pixi environment here.
pixi run --clean-env "echo \$PATH"
```

!!! info
Expand Down
1 change: 1 addition & 0 deletions docs/reference/project_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ alias = { depends-on=["depending"]}
download = { cmd="curl -o file.txt https://example.com/file.txt" , outputs=["file.txt"]}
build = { cmd="npm build", cwd="frontend", inputs=["frontend/package.json", "frontend/*.js"]}
run = { cmd="python run.py $ARGUMENT", env={ ARGUMENT="value" }}
clean-env = { cmd = "python isolated.py", clean-env = true} # Only on Unix!
```

You can modify this table using [`pixi task`](cli.md#task).
Expand Down
546 changes: 354 additions & 192 deletions examples/cpp-sdl/pixi.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/cpp-sdl/pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ sdl2 = "2.26.5.*"
cmake = "3.26.4.*"
cxx-compiler = "1.5.2.*"
ninja = "1.11.1.*"
make = ">=4.3,<5"

[feature.build.tasks.configure]
# Configures CMake
Expand Down
759 changes: 384 additions & 375 deletions pixi.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion schema/examples/valid/full.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ test4 = { cmd = "pytest", cwd = "tests", depends-on = ["test2"] }
test5 = { cmd = "pytest" }
test6 = { depends-on = ["test5"] }
test7 = { cmd = "pytest", cwd = "tests", depends-on = ["test5"], env = {PYTHONPATH = "bla", "WEIRD_STRING" = "blu"}}

test8 = { cmd = "pytest", cwd = "tests", depends-on = ["test5"], env = {PYTHONPATH = "bla", "WEIRD_STRING" = "blu"}, clean-env = true}
test9 = { cmd = "pytest", clean-env = false}
[system-requirements]
linux = "5.10"
libc = { family="glibc", version="2.17" }
Expand Down
5 changes: 5 additions & 0 deletions schema/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ class TaskInlineTable(StrictBaseModel):
description="A map of environment variables to values, used in the task, these will be overwritten by the shell.",
examples=[{"key": "value"}, {"ARGUMENT": "value"}],
)
clean_env: bool | None = Field(
None,
alias="clean-env",
description="Whether to run in a clean environment, removing all environment variables except those defined in `env` and by pixi itself.",
)


#######################
Expand Down
5 changes: 5 additions & 0 deletions schema/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
"clean-env": {
"title": "Clean-Env",
"description": "Whether to run in a clean environment, removing all environment variables except those defined in `env` and by pixi itself.",
"type": "boolean"
},
"cmd": {
"title": "Cmd",
"description": "A shell command to run the task in the limited, but cross-platform `bash`-like `deno_task_shell`. See the documentation for [supported syntax](https://pixi.sh/latest/features/advanced_tasks/#syntax)",
Expand Down
109 changes: 104 additions & 5 deletions src/activation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ pub fn get_activator<'p>(
/// Runs and caches the activation script.
pub async fn run_activation(
environment: &Environment<'_>,
clean_env: bool,
) -> miette::Result<HashMap<String, String>> {
let activator = get_activator(environment, ShellEnum::default()).map_err(|e| {
miette::miette!(format!(
Expand All @@ -141,6 +142,12 @@ pub async fn run_activation(
))
})?;

let path_modification_behavior = if clean_env {
PathModificationBehavior::Replace
} else {
PathModificationBehavior::Prepend
};

let activator_result = match tokio::task::spawn_blocking(move || {
// Run and cache the activation script
activator.run_activation(ActivationVariables {
Expand All @@ -151,7 +158,7 @@ pub async fn run_activation(
conda_prefix: None,

// Prepending environment paths so they get found first.
path_modification_behavior: PathModificationBehavior::Prepend,
path_modification_behavior,
})
})
.await
Expand Down Expand Up @@ -186,7 +193,47 @@ pub async fn run_activation(
}
};

Ok(activator_result)
if clean_env && cfg!(windows) {
return Err(miette::miette!(
format!("It's not possible to run a `clean-env` on Windows as it requires so many non conda specific files that it is basically useless to use. \
So pixi currently doesn't support this feature.")));
} else if clean_env {
let mut cleaned_environment_variables = get_clean_environment_variables();

// Extend with the original activation environment
cleaned_environment_variables.extend(activator_result);

// Enable this when we found a better way to support windows.
// On Windows the path is not completely replace, but we need to strip some paths to keep it as clean as possible.
// if cfg!(target_os = "windows") {
// let path = env
// .get("Path")
// .map(|path| {
// // Keep some of the paths
// let win_path = std::env::split_paths(&path).filter(|p| {
// // Required for base functionalities
// p.to_string_lossy().contains(":\\Windows")
// // Required for compilers
// || p.to_string_lossy().contains("\\Program Files")
// // Required for pixi environments
// || p.starts_with(environment.dir())
// });
// // Join back up the paths
// std::env::join_paths(win_path).expect("Could not join paths")
// })
// .expect("Could not find PATH in environment variables");
// // Insert the path back into the env.
// env.insert(
// "Path".to_string(),
// path.to_str()
// .expect("Path contains non-utf8 paths")
// .to_string(),
// );
// }

return Ok(cleaned_environment_variables);
}
Ok(std::env::vars().chain(activator_result).collect())
}

/// Get the environment variables that are statically generated from the project and the environment.
Expand All @@ -213,6 +260,45 @@ pub fn get_environment_variables<'p>(environment: &'p Environment<'p>) -> HashMa
.collect()
}

pub fn get_clean_environment_variables() -> HashMap<String, String> {
let env = std::env::vars().collect::<HashMap<_, _>>();

let unix_keys = if cfg!(unix) {
vec![
"DISPLAY",
"LC_ALL",
"LC_TIME",
"LC_NUMERIC",
"LC_MEASUREMENT",
"SHELL",
"USER",
"USERNAME",
"LOGNAME",
"HOME",
"HOSTNAME",
]
} else {
vec![]
};

let macos_keys = if cfg!(target_os = "macos") {
vec!["TMPDIR", "XPC_SERVICE_NAME", "XPC_FLAGS"]
} else {
vec![]
};

let keys = unix_keys
.into_iter()
.chain(macos_keys)
// .chain(windows_keys)
.map(|s| s.to_string().to_uppercase())
.collect_vec();

env.into_iter()
.filter(|(key, _)| keys.contains(&key.to_string().to_uppercase()))
.collect::<HashMap<String, String>>()
}

/// Determine the environment variables that need to be set in an interactive shell to make it
/// function as if the environment has been activated. This method runs the activation scripts from
/// the environment and stores the environment variables it added, finally it adds environment
Expand All @@ -224,7 +310,10 @@ pub async fn get_activation_env<'p>(
// Get the prefix which we can then activate.
get_up_to_date_prefix(environment, lock_file_usage, false).await?;

environment.project().get_env_variables(environment).await
environment
.project()
.get_env_variables(environment, false)
.await
}

#[cfg(test)]
Expand Down Expand Up @@ -253,14 +342,13 @@ mod tests {

let default_env = project.default_environment();
let env = default_env.get_metadata_env();
dbg!(&env);

assert_eq!(env.get("PIXI_ENVIRONMENT_NAME").unwrap(), "default");
assert!(env.get("PIXI_ENVIRONMENT_PLATFORMS").is_some());
assert!(env.get("PIXI_PROMPT").unwrap().contains("pixi"));

let test_env = project.environment("test").unwrap();
let env = test_env.get_metadata_env();
dbg!(&env);

assert_eq!(env.get("PIXI_ENVIRONMENT_NAME").unwrap(), "test");
assert!(env.get("PIXI_PROMPT").unwrap().contains("pixi"));
Expand Down Expand Up @@ -294,4 +382,15 @@ mod tests {
&project.version().as_ref().unwrap().to_string()
);
}

#[test]
#[cfg(target_os = "unix")]
fn test_get_linux_clean_environment_variables() {
let env = get_clean_environment_variables();
// Make sure that the environment variables are set.
assert_eq!(
env.get("USER").unwrap(),
std::env::var("USER").as_ref().unwrap()
);
}
}
26 changes: 15 additions & 11 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use dialoguer::theme::ColorfulTheme;
use itertools::Itertools;
use miette::{miette, Context, Diagnostic, IntoDiagnostic};

use crate::activation::get_environment_variables;
use crate::environment::verify_prefix_location_unchanged;
use crate::project::errors::UnsupportedPlatformError;
use crate::task::{
Expand Down Expand Up @@ -47,6 +46,12 @@ pub struct Args {

#[clap(flatten)]
pub config: ConfigCli,

/// Use a clean environment to run the task
///
/// Using this flag will ignore your current shell environment and use bare minimum environment to activate the pixi environment in.
#[arg(long)]
pub clean_env: bool,
}

/// CLI entry point for `pixi run`
Expand Down Expand Up @@ -166,8 +171,12 @@ pub async fn execute(args: Args) -> miette::Result<()> {
let task_env: &_ = match task_envs.entry(executable_task.run_environment.clone()) {
Entry::Occupied(env) => env.into_mut(),
Entry::Vacant(entry) => {
let command_env =
get_task_env(&mut lock_file, &executable_task.run_environment).await?;
let command_env = get_task_env(
&mut lock_file,
&executable_task.run_environment,
args.clean_env || executable_task.task().clean_env(),
)
.await?;
entry.insert(command_env)
}
};
Expand Down Expand Up @@ -230,6 +239,7 @@ fn command_not_found<'p>(project: &'p Project, explicit_environment: Option<Envi
pub async fn get_task_env<'p>(
lock_file_derived_data: &mut LockFileDerivedData<'p>,
environment: &Environment<'p>,
clean_env: bool,
) -> miette::Result<HashMap<String, String>> {
// Make sure the system requirements are met
verify_current_platform_has_required_virtual_packages(environment).into_diagnostic()?;
Expand All @@ -239,19 +249,13 @@ pub async fn get_task_env<'p>(

// Get environment variables from the activation
let activation_env = await_in_progress("activating environment", |_| {
crate::activation::run_activation(environment)
crate::activation::run_activation(environment, clean_env)
})
.await
.wrap_err("failed to activate environment")?;

// Get environments from pixi
let environment_variables = get_environment_variables(environment);

// Concatenate with the system environment variables
Ok(std::env::vars()
.chain(activation_env)
.chain(environment_variables)
.collect())
Ok(activation_env)
}

#[derive(Debug, Error, Diagnostic)]
Expand Down
5 changes: 4 additions & 1 deletion src/cli/shell_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ async fn generate_activation_script(
/// Generates a JSON object describing the changes to the shell environment when
/// activating the provided pixi environment.
async fn generate_environment_json(environment: &Environment<'_>) -> miette::Result<String> {
let environment_variables = environment.project().get_env_variables(environment).await?;
let environment_variables = environment
.project()
.get_env_variables(environment, false)
.await?;
let shell_env = ShellEnv {
environment_variables,
};
Expand Down
6 changes: 6 additions & 0 deletions src/cli/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ pub struct AddArgs {
/// The environment variable to set, use --env key=value multiple times for more than one variable
#[arg(long, value_parser = parse_key_val)]
pub env: Vec<(String, String)>,

/// Isolate the task from the shell environment, and only use the pixi environment to run the task
#[arg(long)]
pub clean_env: bool,
}

/// Parse a single key-value pair
Expand Down Expand Up @@ -149,6 +153,7 @@ impl From<AddArgs> for Task {
} else if depends_on.is_empty() && value.cwd.is_none() && value.env.is_empty() {
Self::Plain(cmd_args)
} else {
let clean_env = value.clean_env;
let cwd = value.cwd;
let env = if value.env.is_empty() {
None
Expand All @@ -166,6 +171,7 @@ impl From<AddArgs> for Task {
outputs: None,
cwd,
env,
clean_env,
})
}
}
Expand Down
Loading

0 comments on commit 818d2ec

Please sign in to comment.