diff --git a/CHANGELOG.md b/CHANGELOG.md index 4424523659..818f42ce2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ _Unreleased_ - Fix incorrect flag passing of `rye test` `-q` and `-v`. #880 +- Rye now loads `.env` files. This applies both for Rye's own + use of environment variables but also to scripts launched via + `run`. #894 + - Fix `rye add m --path ./m` causing a panic on windows. #897 diff --git a/Cargo.lock b/Cargo.lock index 7bf29b3126..763afd5d39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,12 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.9.0" @@ -1808,6 +1814,7 @@ dependencies = [ "curl", "decompress", "dialoguer", + "dotenvy", "flate2", "fslock", "git-testament", diff --git a/docs/guide/commands/index.md b/docs/guide/commands/index.md index d40257ab98..07c99679df 100644 --- a/docs/guide/commands/index.md +++ b/docs/guide/commands/index.md @@ -23,4 +23,12 @@ This is a list of all the commands that rye provides: * [tools](tools/index.md): Helper utility to manage global tools. * [self](self/index.md): Rye self management * [uninstall](uninstall.md): Uninstalls a global tool (alias) -* [version](version.md): Get or set project version \ No newline at end of file +* [version](version.md): Get or set project version + +## Options + +The toplevel `rye` command accepts the following options: + +* `--env-file` ``: This can be supplied multiple times to make rye load + a given `.env` file. Note that this file is not referenced to handle the + `RYE_HOME` variable which must be supplied as environment variable always. \ No newline at end of file diff --git a/docs/guide/pyproject.md b/docs/guide/pyproject.md index 832b6b0309..34db616b37 100644 --- a/docs/guide/pyproject.md +++ b/docs/guide/pyproject.md @@ -158,6 +158,18 @@ This key can be used to provide environment variables with a script: devserver = { cmd = "flask run --debug", env = { FLASK_APP = "./hello.py" } } ``` +### `env-file` + ++++ 0.30.0 + +This is similar to `env` but rather than setting environment variables directly, it instead +points to a file that should be loaded (relative to the `pyproject.toml`): + +```toml +[tool.rye.scripts] +devserver = { cmd = "flask run --debug", env-file = ".dev.env" } +``` + ### `chain` This is a special key that can be set instead of `cmd` to make a command invoke multiple diff --git a/rye/Cargo.toml b/rye/Cargo.toml index 1d6751fe9e..f03ad2f428 100644 --- a/rye/Cargo.toml +++ b/rye/Cargo.toml @@ -60,6 +60,7 @@ python-pkginfo = { version = "0.6.0", features = ["serde"] } sysinfo = { version = "0.29.4", default-features = false, features = [] } home = "0.5.9" ctrlc = "3.4.2" +dotenvy = "0.15.7" [target."cfg(unix)".dependencies] xattr = "1.3.1" diff --git a/rye/src/cli/mod.rs b/rye/src/cli/mod.rs index 7d8232b882..502000da81 100644 --- a/rye/src/cli/mod.rs +++ b/rye/src/cli/mod.rs @@ -1,4 +1,5 @@ use std::env; +use std::path::PathBuf; use anyhow::{bail, Error}; use clap::Parser; @@ -34,6 +35,7 @@ use crate::bootstrap::{get_self_venv_status, SELF_PYTHON_TARGET_VERSION}; use crate::config::Config; use crate::platform::symlinks_supported; use crate::pyproject::read_venv_marker; +use crate::utils::IoPathContext; git_testament!(TESTAMENT); @@ -43,6 +45,9 @@ git_testament!(TESTAMENT); struct Args { #[command(subcommand)] command: Option, + /// Load one or more .env files. + #[arg(long)] + env_file: Vec, /// Print the version #[arg(long)] version: bool, @@ -102,6 +107,13 @@ pub fn execute() -> Result<(), Error> { } let args = Args::try_parse()?; + + // handle --env-file. As this happens here this cannot influence `RYE_HOME` or + // the behavior of the shims. + for env_file in &args.env_file { + dotenvy::from_path(env_file).path_context(env_file, "unable to load env file")?; + } + let cmd = if args.version { return print_version(); } else if let Some(cmd) = args.command { diff --git a/rye/src/cli/run.rs b/rye/src/cli/run.rs index 0daf1980b6..7db917d72b 100644 --- a/rye/src/cli/run.rs +++ b/rye/src/cli/run.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::env::{self, join_paths, split_paths}; use std::ffi::OsString; use std::path::PathBuf; @@ -10,7 +11,7 @@ use console::style; use crate::pyproject::{PyProject, Script}; use crate::sync::{sync, SyncOptions}; use crate::tui::redirect_to_stderr; -use crate::utils::{exec_spawn, get_venv_python_bin, success_status}; +use crate::utils::{exec_spawn, get_venv_python_bin, success_status, IoPathContext}; /// Runs a command installed into this package. #[derive(Parser, Debug)] @@ -62,9 +63,9 @@ fn invoke_script( let mut env_overrides = None; match pyproject.get_script_cmd(&args[0].to_string_lossy()) { - Some(Script::Call(entry, env_vars)) => { + Some(Script::Call(entry, env_vars, env_file)) => { let py = OsString::from(get_venv_python_bin(&pyproject.venv_path())); - env_overrides = Some(env_vars); + env_overrides = Some(load_env_vars(pyproject, env_file, env_vars)?); args = if let Some((module, func)) = entry.split_once(':') { if module.is_empty() || func.is_empty() { bail!("Python callable must be in the form : or ") @@ -86,11 +87,11 @@ fn invoke_script( .chain(args.into_iter().skip(1)) .collect(); } - Some(Script::Cmd(script_args, env_vars)) => { + Some(Script::Cmd(script_args, env_vars, env_file)) => { if script_args.is_empty() { bail!("script has no arguments"); } - env_overrides = Some(env_vars); + env_overrides = Some(load_env_vars(pyproject, env_file, env_vars)?); let script_target = venv_bin.join(&script_args[0]); if script_target.is_file() { args = Some(script_target.as_os_str().to_owned()) @@ -157,6 +158,23 @@ fn invoke_script( } } +fn load_env_vars( + pyproject: &PyProject, + env_file: Option, + mut env_vars: HashMap, +) -> Result, Error> { + if let Some(ref env_file) = env_file { + let env_file = pyproject.root_path().join(env_file); + for item in + dotenvy::from_path_iter(&env_file).path_context(&env_file, "could not load env-file")? + { + let (k, v) = item.path_context(&env_file, "invalid value in env-file")?; + env_vars.insert(k, v); + } + } + Ok(env_vars) +} + fn list_scripts(pyproject: &PyProject) -> Result<(), Error> { let mut scripts: Vec<_> = pyproject .list_scripts() diff --git a/rye/src/pyproject.rs b/rye/src/pyproject.rs index 73dc748fa6..fc662af7a6 100644 --- a/rye/src/pyproject.rs +++ b/rye/src/pyproject.rs @@ -203,14 +203,15 @@ impl SourceRef { } type EnvVars = HashMap; +type EnvFile = Option; /// A reference to a script #[derive(Clone, Debug)] pub enum Script { /// Call python module entry - Call(String, EnvVars), + Call(String, EnvVars, EnvFile), /// A command alias - Cmd(Vec, EnvVars), + Cmd(Vec, EnvVars, EnvFile), /// A multi-script execution Chain(Vec>), /// External script reference @@ -257,11 +258,19 @@ impl Script { env_vars } + fn get_env_file(detailed: &dyn TableLike) -> EnvFile { + detailed + .get("env-file") + .and_then(|x| x.as_str()) + .map(PathBuf::from) + } + if let Some(detailed) = item.as_table_like() { if let Some(call) = detailed.get("call") { let entry = call.as_str()?.to_string(); let env_vars = get_env_vars(detailed); - Some(Script::Call(entry, env_vars)) + let env_file = get_env_file(detailed); + Some(Script::Call(entry, env_vars, env_file)) } else if let Some(cmds) = detailed.get("chain").and_then(|x| x.as_array()) { Some(Script::Chain( cmds.iter().flat_map(toml_value_as_command_args).collect(), @@ -269,13 +278,14 @@ impl Script { } else if let Some(cmd) = detailed.get("cmd") { let cmd = toml_value_as_command_args(cmd.as_value()?)?; let env_vars = get_env_vars(detailed); - Some(Script::Cmd(cmd, env_vars)) + let env_file = get_env_file(detailed); + Some(Script::Cmd(cmd, env_vars, env_file)) } else { None } } else { toml_value_as_command_args(item.as_value()?) - .map(|cmd| Script::Cmd(cmd, EnvVars::default())) + .map(|cmd| Script::Cmd(cmd, EnvVars::default(), None)) } } } @@ -288,7 +298,7 @@ fn shlex_quote_unsafe(s: &str) -> Cow<'_, str> { impl fmt::Display for Script { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Script::Call(entry, env) => { + Script::Call(entry, env, env_file) => { write!(f, "{}", shlex_quote_unsafe(entry))?; if !env.is_empty() { write!(f, " (env: ")?; @@ -305,9 +315,12 @@ impl fmt::Display for Script { } write!(f, ")")?; } + if let Some(ref env_file) = env_file { + write!(f, " (env-file: {})", env_file.display())?; + } Ok(()) } - Script::Cmd(args, env) => { + Script::Cmd(args, env, env_file) => { let mut need_space = false; for (key, value) in env.iter() { if need_space { @@ -328,6 +341,9 @@ impl fmt::Display for Script { write!(f, "{}", shlex_quote_unsafe(arg))?; need_space = true; } + if let Some(ref env_file) = env_file { + write!(f, " (env-file: {})", env_file.display())?; + } Ok(()) } Script::Chain(cmds) => { diff --git a/rye/tests/test_cli.rs b/rye/tests/test_cli.rs new file mode 100644 index 0000000000..10c6765d6a --- /dev/null +++ b/rye/tests/test_cli.rs @@ -0,0 +1,51 @@ +use std::fs; + +use toml_edit::value; + +use crate::common::{rye_cmd_snapshot, Space}; + +mod common; + +#[test] +fn test_dotenv() { + let space = Space::new(); + space.init("my-project"); + space.edit_toml("pyproject.toml", |doc| { + doc["tool"]["rye"]["scripts"]["hello"]["cmd"] = + value("python -c \"import os; print(os.environ['MY_COOL_VAR'], os.environ['MY_COOL_OTHER_VAR'])\""); + doc["tool"]["rye"]["scripts"]["hello"]["env-file"] = value(".other.env"); + }); + fs::write(space.project_path().join(".env"), "MY_COOL_VAR=42").unwrap(); + fs::write( + space.project_path().join(".other.env"), + "MY_COOL_OTHER_VAR=23", + ) + .unwrap(); + rye_cmd_snapshot!(space.rye_cmd().arg("sync"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Initializing new virtualenv in [TEMP_PATH]/project/.venv + Python version: cpython@3.12.2 + Generating production lockfile: [TEMP_PATH]/project/requirements.lock + Generating dev lockfile: [TEMP_PATH]/project/requirements-dev.lock + Installing dependencies + Done! + + ----- stderr ----- + Built 1 editable in [EXECUTION_TIME] + Installed 1 package in [EXECUTION_TIME] + + my-project==0.1.0 (from file:[TEMP_PATH]/project) + "###); + rye_cmd_snapshot!(space.rye_cmd() + .arg("--env-file=.env") + .arg("run") + .arg("hello"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + 42 23 + + ----- stderr ----- + "###); +}