Expand environment variables in URLs (#640)

## Summary

This PR enables users to express relative dependencies via environment
variables. Like pip, PDM, Hatch, Rye, and others, we now allow users to
express dependencies like:

```text
flask @ file://${PROJECT_ROOT}/flask-3.0.0-py3-none-any.whl
```

In the compiled requirements file, we'll also preserve the unexpanded
environment variable.

Closes https://github.com/astral-sh/puffin/issues/592.
This commit is contained in:
Charlie Marsh 2023-12-14 10:09:12 -05:00 committed by GitHub
parent ed8dfbfcf7
commit 22c7057b35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 99 additions and 2 deletions

View file

@ -1,4 +1,8 @@
use std::borrow::Cow;
use std::ops::Deref;
use once_cell::sync::Lazy;
use regex::Regex;
use url::Url;
/// A wrapper around [`Url`] that preserves the original string.
@ -14,9 +18,9 @@ pub struct VerbatimUrl {
}
impl VerbatimUrl {
/// Parse a URL from a string.
/// Parse a URL from a string, expanding any environment variables.
pub fn parse(given: String) -> Result<Self, Error> {
let url = Url::parse(&given)?;
let url = Url::parse(&expand_env_vars(&given))?;
Ok(Self {
given: Some(given),
url,
@ -73,3 +77,38 @@ pub enum Error {
#[error(transparent)]
Url(#[from] url::ParseError),
}
/// Expand all available environment variables.
///
/// This is modeled off of pip's environment variable expansion, which states:
///
/// The only allowed format for environment variables defined in the
/// requirement file is `${MY_VARIABLE_1}` to ensure two things:
///
/// 1. Strings that contain a `$` aren't accidentally (partially) expanded.
/// 2. Ensure consistency across platforms for requirement files.
///
/// ...
///
/// Valid characters in variable names follow the `POSIX standard
/// <http://pubs.opengroup.org/onlinepubs/9699919799/>`_ and are limited
/// to uppercase letter, digits and the `_` (underscore).
fn expand_env_vars(s: &str) -> Cow<'_, str> {
// Generate a URL-escaped project root, to be used via the `${PROJECT_ROOT}`
// environment variable. Ensure that it's URL-escaped.
static PROJECT_ROOT_FRAGMENT: Lazy<String> = Lazy::new(|| {
let project_root = std::env::current_dir().unwrap();
project_root.to_string_lossy().replace(' ', "%20")
});
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?P<var>\$\{(?P<name>[A-Z0-9_]+)})").unwrap());
RE.replace_all(s, |caps: &regex::Captures<'_>| {
let name = caps.name("name").unwrap().as_str();
std::env::var(name).unwrap_or_else(|_| match name {
"PROJECT_ROOT" => PROJECT_ROOT_FRAGMENT.clone(),
_ => caps["var"].to_owned(),
})
})
}