Skip to content

Commit bf91b10

Browse files
committed
Fix Pipenv .env test and escapes on Windows
The new .env test removed the whole WORKON_HOME on Windows, where site-packages sits one level shallower, so the .env-named venv went with it. .env values now decode exactly python-dotenv's escapes, so a backslash in a quoted Windows path is kept as written. Assisted-by: Claude Code:claude-opus-5-5
1 parent 6e23849 commit bf91b10

2 files changed

Lines changed: 59 additions & 29 deletions

File tree

‎crates/socket-patch-cli/tests/in_process_python_envs.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,8 @@ async fn pipenv_dotenv_settings_pick_the_scanned_venv() {
689689
project.join(".env"),
690690
format!(
691691
"export WORKON_HOME=\"{}\"\nPIPENV_CUSTOM_VENV_NAME=proj-env # named\n",
692-
workon.display()
692+
// python-dotenv decodes `\r`, `\t`... in double quotes.
693+
workon.display().to_string().replace('\\', "/")
693694
),
694695
)
695696
.unwrap();

‎crates/socket-patch-core/src/crawlers/python_crawler.rs‎

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -789,32 +789,9 @@ fn parse_dotenv(text: &str, var: &impl Fn(&str) -> Option<String>) -> Vec<(Strin
789789
}
790790
let raw = raw.trim_start();
791791
let (value, interpolate) = if let Some(rest) = raw.strip_prefix('\'') {
792-
(
793-
rest.split('\'').next().unwrap_or_default().to_string(),
794-
false,
795-
)
792+
(dotenv_unquote(rest, '\''), false)
796793
} else if let Some(rest) = raw.strip_prefix('"') {
797-
let mut value = String::new();
798-
let mut chars = rest.chars();
799-
while let Some(c) = chars.next() {
800-
match c {
801-
'"' => break,
802-
'\\' => match chars.next() {
803-
Some('n') => value.push('\n'),
804-
Some('t') => value.push('\t'),
805-
Some('r') => value.push('\r'),
806-
Some(other) => {
807-
if !matches!(other, '"' | '\\' | '\'') {
808-
value.push('\\');
809-
}
810-
value.push(other);
811-
}
812-
None => value.push('\\'),
813-
},
814-
c => value.push(c),
815-
}
816-
}
817-
(value, true)
794+
(dotenv_unquote(rest, '"'), true)
818795
} else {
819796
let unquoted = match raw.find(" #").or_else(|| raw.find("\t#")) {
820797
Some(at) => &raw[..at],
@@ -840,6 +817,45 @@ fn parse_dotenv(text: &str, var: &impl Fn(&str) -> Option<String>) -> Vec<(Strin
840817
pairs
841818
}
842819

820+
/// The body of a quoted python-dotenv value up to the closing `quote`,
821+
/// decoding only the escapes python-dotenv decodes: `\\` and `\'` in single
822+
/// quotes; those plus `\"` and `\a \b \f \n \r \t \v` in double quotes. Any
823+
/// other backslash stays literal, so Windows paths survive.
824+
fn dotenv_unquote(rest: &str, quote: char) -> String {
825+
let mut value = String::new();
826+
let mut chars = rest.chars().peekable();
827+
while let Some(c) = chars.next() {
828+
if c == quote {
829+
break;
830+
}
831+
if c != '\\' {
832+
value.push(c);
833+
continue;
834+
}
835+
let decoded = match (quote, chars.peek()) {
836+
(_, Some('\\')) => Some('\\'),
837+
(_, Some('\'')) => Some('\''),
838+
('"', Some('"')) => Some('"'),
839+
('"', Some('a')) => Some('\u{7}'),
840+
('"', Some('b')) => Some('\u{8}'),
841+
('"', Some('f')) => Some('\u{c}'),
842+
('"', Some('n')) => Some('\n'),
843+
('"', Some('r')) => Some('\r'),
844+
('"', Some('t')) => Some('\t'),
845+
('"', Some('v')) => Some('\u{b}'),
846+
_ => None,
847+
};
848+
match decoded {
849+
Some(decoded) => {
850+
chars.next();
851+
value.push(decoded);
852+
}
853+
None => value.push('\\'),
854+
}
855+
}
856+
value
857+
}
858+
843859
/// python-dotenv's `${NAME}` / `${NAME:-default}` expansion (a bare
844860
/// `$NAME` stays literal); an unset name takes the default, else "".
845861
fn dotenv_interpolate(value: &str, lookup: &impl Fn(&str) -> Option<String>) -> String {
@@ -3512,7 +3528,11 @@ mod tests {
35123528
find_local_venv_site_packages_with(&project, &var).await,
35133529
vec![custom.clone(), site.clone()]
35143530
);
3515-
std::fs::remove_dir_all(site.ancestors().nth(3).unwrap()).unwrap();
3531+
// Remove only the default venv (`wh/proj-<hash>`); the site-packages
3532+
// depth under it differs between POSIX and Windows.
3533+
let wh = tmp.path().join("wh");
3534+
let default_root = site.ancestors().find(|a| a.parent() == Some(wh.as_path()));
3535+
std::fs::remove_dir_all(default_root.unwrap()).unwrap();
35163536
assert_eq!(
35173537
find_local_venv_site_packages_with(&project, &var).await,
35183538
vec![custom.clone()],
@@ -3539,7 +3559,9 @@ mod tests {
35393559
std::fs::write(project.join("Pipfile"), "[packages]\nsix = \"==1.16.0\"\n").unwrap();
35403560
let real = std::fs::canonicalize(&project).unwrap();
35413561
let hash = pipenv_venv_hash(&pipenv_path_string(&real.join("Pipfile")));
3542-
let base = tmp.path().to_string_lossy().into_owned();
3562+
// Forward slashes: python-dotenv decodes `\r`, `\t`... inside double
3563+
// quotes, so a quoted Windows path must not carry backslashes.
3564+
let base = tmp.path().to_string_lossy().replace('\\', "/");
35433565
let elsewhere = fake_venv(&tmp.path().join("elsewhere"), &format!("proj-{hash}"));
35443566
let home = env_of(&[(
35453567
"HOME",
@@ -3615,6 +3637,9 @@ C=\"quoted # not a comment\"
36153637
D='single ${OUTER}'
36163638
E=\"line\\nbreak\"
36173639
F=${A}-${OUTER}-${MISSING:-dflt}
3640+
H=\"C:\\Users\\dev\\Temp\"
3641+
I='it\\'s'
3642+
J='C:\\Temp\\x'
36183643
NOVALUE
36193644
G=
36203645
=skipped
@@ -3629,7 +3654,11 @@ G=
36293654
assert_eq!(get("F"), Some("1-out-dflt"));
36303655
assert_eq!(get("NOVALUE"), None);
36313656
assert_eq!(get("G"), Some(""));
3632-
assert_eq!(parsed.len(), 7);
3657+
// Unknown escapes stay literal (Windows paths); `\'` and `\\` decode.
3658+
assert_eq!(get("H"), Some(r"C:\Users\dev\Temp"));
3659+
assert_eq!(get("I"), Some("it's"));
3660+
assert_eq!(get("J"), Some(r"C:\Temp\x"));
3661+
assert_eq!(parsed.len(), 10);
36333662
}
36343663

36353664
#[tokio::test]

0 commit comments

Comments
 (0)