Skip to content
Merged
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
133 changes: 133 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,3 +350,136 @@ fn pipenv_every_major_hosted_and_vendored_end_in_manifest_less_vex() {
}
assert!(failures.is_empty(), "{}", failures.join("\n\n"));
}

const PYLOCK_PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[dev-packages]\n\n[pipenv]\nuse_pylock = true\n";

/// #912 / #1122 on a real Pipenv 2026 `use_pylock = true` project, whose
/// `pipenv lock` writes both `Pipfile.lock` and `pylock.toml`:
///
/// * #1122: vendored wires `Pipfile.lock` (what Pipenv installs from when
/// both exist), leaves the pylock alone, and a fresh
/// `pipenv install --deploy` gets the PATCHED six.
/// * #912: the pylock-only checkout (no `Pipfile.lock`) is refused in both
/// modes — Pipenv would drop the `archive` entry and install the upstream
/// release — and the pylock is left byte-identical.
fn pylock_flow(pipenv: &Pipenv, root: &Path) {
let v = pipenv.version.as_str();
let what = format!("pipenv {v} use_pylock");
let proj = root.join("pylock").join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("Pipfile"), PYLOCK_PIPFILE).unwrap();
let python = venv_bin(&pipenv.venv, "python");
pipenv.project_venv(&proj);
let out = pipenv.run(&proj, &["install", "--python", python.to_str().unwrap()]);
assert_ok(&out, &format!("{what}: pipenv install"));
if !proj.join("pylock.toml").is_file() {
assert_ok(
&pipenv.run(&proj, &["lock"]),
&format!("{what}: pipenv lock"),
);
}
let pristine_pylock = read(&proj.join("pylock.toml"));
let py = venv_bin(&proj.join(".venv"), "python");
let (_, pristine, _) =
six_oracle(&py, &proj).unwrap_or_else(|| panic!("{what}: six not importable"));
let patched = [pristine.as_slice(), PATCH_SUFFIX].concat();

// #912: the pylock-only checkout, both modes.
for mode in [Mode::Hosted, Mode::Vendored] {
let only = root.join("pylock").join(format!("only-{}", mode.label()));
copy_tree(&proj, &only, &[".venv"]);
std::fs::remove_file(only.join("Pipfile.lock")).unwrap();
let api = RealApi::start(mode.uuid(), &pristine, &patched);
let (_, env, stderr) = socket_scan(&only, &api, mode.scan_flags(), &pipenv.envs);
let code = match mode {
Mode::Hosted => "redirect_pipenv_pylock_unsupported",
Mode::Vendored => "pypi_pipenv_pylock_unsupported",
};
let ok = env.to_string().contains(code)
&& read(&only.join("pylock.toml")) == pristine_pylock
&& !only.join("Pipfile.lock").exists();
record(
"pipenv",
v,
&format!("pylock-only/{}", mode.label()),
"refused",
if ok { "pass" } else { "FAIL" },
);
assert!(
ok,
"{what} pylock-only {}: expected {code}, pylock untouched: {env}\n{stderr}",
mode.label()
);
}

// #1122: both locks, vendored.
let mode = Mode::Vendored;
let api = RealApi::start(mode.uuid(), &pristine, &patched);
let (code, env, stderr) = socket_scan(&proj, &api, mode.scan_flags(), &pipenv.envs);
assert_eq!(code, Some(0), "{what}: vendored scan: {env}\n{stderr}");
let lock = String::from_utf8(read(&proj.join("Pipfile.lock"))).unwrap();
assert!(
lock.contains(&format!(".socket/vendor/pypi/{}/", mode.uuid())),
"{what}: Pipfile.lock must point at the vendored wheel: {lock}"
);
assert_eq!(
read(&proj.join("pylock.toml")),
pristine_pylock,
"{what}: the pylock Pipenv ignores is left alone"
);
let fresh = root.join("pylock").join("fresh");
copy_tree(&proj, &fresh, &[".venv"]);
pipenv.project_venv(&fresh);
let out = pipenv.run(
&fresh,
&["install", "--deploy", "--python", python.to_str().unwrap()],
);
assert_ok(&out, &format!("{what}: fresh `pipenv install --deploy`"));
let (_, bytes, is_patched) = six_oracle(&venv_bin(&fresh.join(".venv"), "python"), &fresh)
.unwrap_or_else(|| panic!("{what}: six not importable in the fresh checkout"));
let ok = is_patched && bytes == patched;
record(
"pipenv",
v,
"use_pylock/vendored",
"install-patched",
if ok { "pass" } else { "FAIL" },
);
assert!(ok, "{what}: the fresh install must carry the PATCHED six");
}

#[test]
#[ignore = "real Pipenv releases + PyPI (network). Run with --ignored."]
fn pipenv_pylock_projects_wire_what_pipenv_installs() {
let Some(uv) = find_uv() else {
return skip_or_fail(REQUIRED, "uv is not installed");
};
let mut failures = Vec::new();
// `[pipenv] use_pylock` is a Pipenv 2026 setting.
for version in versions(VERSIONS_VAR, VERSIONS)
.into_iter()
.filter(|v| year(v) >= 2026)
{
let scratch = tempfile::tempdir().unwrap();
let pipenv = match Pipenv::bootstrap(&uv, &version, scratch.path()) {
Ok(p) => p,
Err(e) => {
record("pipenv", &version, "use_pylock", "bootstrap", "SKIP");
skip_or_fail(REQUIRED, &format!("pipenv {version} bootstrap: {e}"));
continue;
}
};
let root = scratch.path().join("run");
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| pylock_flow(&pipenv, &root)));
if let Err(e) = result {
let msg = e
.downcast_ref::<String>()
.cloned()
.or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
failures.push(format!("pipenv {version} use_pylock: {msg}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n\n"));
}
6 changes: 5 additions & 1 deletion crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,14 +268,18 @@ pub fn make_venv(uv: &Path, python: &str, venv: &Path, pins: &[&str]) -> Result<
}

/// `<tools_root>/<name>` made by [`make_venv`] unless `probe` already runs
/// there (bootstraps are reused across runs).
/// there (bootstraps are reused across runs). Serialized: tests in one
/// binary run in parallel, and two of them bootstrapping the same venv
/// race `uv venv` into "a virtual environment already exists".
pub fn bootstrap_tool(
uv: &Path,
name: &str,
python: &str,
pins: &[&str],
probe: &str,
) -> Result<PathBuf, String> {
static BOOTSTRAP: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = BOOTSTRAP.lock().unwrap_or_else(|e| e.into_inner());
let venv = tools_root().join(name);
let exe = venv_bin(&venv, probe);
let healthy = |exe: &Path| {
Expand Down
32 changes: 31 additions & 1 deletion crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ mod lock_index_equivalence_tests;
pub mod npmrc;
mod pdm;
mod pipenv;
#[cfg(test)]
mod pipenv_pylock_tests;
pub mod presence;
// The pnpm hosted planner lives with the format's model.
use crate::formats::cargo::hosted::CargoLockPlan;
Expand Down Expand Up @@ -5185,6 +5187,24 @@ fn record_python_metadata_edit(
result.files.insert(edit.path, edit.rewritten);
}

/// #912: the refusal for a pylock Pipenv installs from
/// ([`pipenv_reads_pylock`]): Pipenv drops the `archive` entry a pin
/// writes, so `pipenv sync` would install the upstream release.
///
/// [`pipenv_reads_pylock`]: crate::utils::python_lock::pipenv_reads_pylock
fn pipenv_pylock_refusal(path: &str, dep: &DepOverride) -> RewriteWarning {
RewriteWarning {
code: "redirect_pipenv_pylock_unsupported".into(),
detail: format!(
"{path} is installed by Pipenv (a Pipfile sits beside it and there is no \
Pipfile.lock), and Pipenv keeps only the version and hashes of a pylock \
entry, so a pin of {}=={} would be dropped and the upstream release \
installed; run `pipenv lock` to write a Pipfile.lock and re-run the scan",
dep.name, dep.version
),
}
}

/// Each lock is parsed once ([`PythonLockSession`]) and every dep is
/// planned, refused or applied against that one document. The lock is still
/// rendered after every rewritten dep: each dep's FileEdit fragments are
Expand All @@ -5197,7 +5217,9 @@ fn rewrite_uv_lock(
python_metadata: &BTreeMap<String, String>,
result: &mut RewriteResult,
) {
use crate::utils::python_lock::{is_python_lock_name, ArtifactSource, PythonLockSession};
use crate::utils::python_lock::{
is_python_lock_name, pipenv_reads_pylock, ArtifactSource, PythonLockSession,
};

let locks: Vec<(&String, &String)> = files
.iter()
Expand All @@ -5223,9 +5245,17 @@ fn rewrite_uv_lock(
for (path, original) in locks {
let mut content = original.clone();
let mut session = PythonLockSession::new(original);
let pipenv_reads = pipenv_reads_pylock(path, |rel| files.contains_key(rel));
for &(dep, sha256) in &usable {
let artifact = ArtifactSource::Url(&dep.artifact_url);
let plan = match session.plan(&content, &dep.name, &dep.version, artifact) {
Ok(Some(_)) if pipenv_reads => {
result
.refused_python_lock_uuids
.insert(dep.patch_uuid.clone());
result.warnings.push(pipenv_pylock_refusal(path, dep));
continue;
Comment thread
mikolalysenko marked this conversation as resolved.
}
Ok(Some(plan)) => plan,
Ok(None) => {
result.warnings.push(RewriteWarning {
Expand Down
176 changes: 176 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/pipenv_pylock_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! #912 / #1122: a PEP 751 `pylock*.toml` beside a `Pipfile` is read by
//! Pipenv, not by a PEP 751 installer. Pipenv's pylock reader
//! (`PylockFile.convert_to_pipenv_lockfile`) keeps only each package's
//! version, marker and wheel / sdist hashes, so the `archive` entry a hosted
//! pin writes is dropped and `pipenv sync` installs the upstream release.
//! With no `Pipfile.lock` (which Pipenv prefers when both exist) the pin is
//! refused; with one, both locks are pinned as before.

use super::*;

const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl";
const UUID: &str = "aaaaaaaa-0000-4000-8000-000000000912";
const HEX: &str = "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254";
const PIPFILE: &str = "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nsix = \"==1.16.0\"\n\n[pipenv]\nuse_pylock = true\n";

fn dep() -> DepOverride {
DepOverride {
ecosystem: "pypi".into(),
name: "six".into(),
namespace: None,
version: "1.16.0".into(),
token: "11111111-1111-4111-8111-111111111111".into(),
patch_uuid: UUID.into(),
artifact_url: format!(
"https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/{UUID}/{WHEEL}"
),
registry_override: None,
integrity: Integrity {
sha256: Some(HEX.into()),
..Default::default()
},
}
}

/// A pylock as `pipenv lock` (use_pylock = true) writes it.
fn pylock() -> String {
format!(
"lock-version = \"1.0\"\nenvironments = []\nextras = []\ndependency-groups = []\ndefault-groups = []\ncreated-by = \"pipenv\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nwheels = [{{ name = \"{WHEEL}\", url = \"https://files.pythonhosted.org/packages/d9/5a/{WHEEL}\", hashes = {{ sha256 = \"{HEX}\" }} }}]\n\n[tool.pipenv]\ngenerated_from = \"Pipfile.lock\"\n"
)
}

fn pipfile_lock() -> String {
format!(
"{{\n \"_meta\": {{\n \"hash\": {{\n \"sha256\": \"unchanged\"\n }},\n \"pipfile-spec\": 6,\n \"sources\": [\n {{\n \"name\": \"pypi\",\n \"url\": \"https://pypi.org/simple\",\n \"verify_ssl\": true\n }}\n ]\n }},\n \"default\": {{\n \"six\": {{\n \"hashes\": [\n \"sha256:{HEX}\"\n ],\n \"index\": \"pypi\",\n \"version\": \"==1.16.0\"\n }}\n }},\n \"develop\": {{}}\n}}\n"
)
}

fn files(entries: &[(&str, String)]) -> BTreeMap<String, String> {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}

fn rewrite(files: &BTreeMap<String, String>) -> RewriteResult {
rewrite_registry_redirect_with_pipenv_version(
files,
&[dep()],
&BTreeMap::new(),
Some(2026),
false,
)
}

/// #912: a pylock-only Pipenv checkout (any `pylock_name`) is refused,
/// loudly, and nothing is written or confirmed for it.
#[test]
fn pylock_read_by_pipenv_is_refused_without_pipfile_lock() {
for (pipfile, lock) in [("Pipfile", "pylock.toml"), ("Pipfile", "pylock.dev.toml")] {
let result = rewrite(&files(&[(pipfile, PIPFILE.into()), (lock, pylock())]));
assert!(
result.files.is_empty() && result.edits.is_empty(),
"{lock}: pinned {:?}",
result.files.keys().collect::<Vec<_>>()
);
assert!(
!result.confirmed_python_lock_uuids.contains(UUID),
"{lock}: confirmed"
);
assert!(result.refused_python_lock_uuids.contains(UUID), "{lock}");
let refusals: Vec<&RewriteWarning> = result
.warnings
.iter()
.filter(|w| w.code == "redirect_pipenv_pylock_unsupported")
.collect();
assert_eq!(refusals.len(), 1, "{lock}: {:?}", result.warnings);
assert!(
refusals[0].detail.contains(lock)
&& refusals[0].detail.contains("pipenv lock")
&& refusals[0].detail.contains("six==1.16.0"),
"{lock}: {}",
refusals[0].detail
);
}
}

/// Controls: a pylock with no Pipfile beside it is pinned as before, and a
/// `use_pylock = true` project with both locks still pins both (#1122's
/// hosted control).
#[test]
fn pylock_without_a_pipenv_consumer_or_beside_pipfile_lock_still_pins() {
let result = rewrite(&files(&[("pylock.toml", pylock())]));
assert!(
result
.files
.get("pylock.toml")
.is_some_and(|t| t.contains(WHEEL) && t.contains("archive")),
"{:?}",
result.warnings
);
assert!(result.confirmed_python_lock_uuids.contains(UUID));

let result = rewrite(&files(&[
("Pipfile", PIPFILE.into()),
("Pipfile.lock", pipfile_lock()),
("pylock.toml", pylock()),
]));
for lock in ["Pipfile.lock", "pylock.toml"] {
assert!(
result.files.get(lock).is_some_and(|t| t.contains(UUID)),
"{lock}: {:?}",
result.warnings
);
}
assert!(result
.warnings
.iter()
.all(|w| w.code != "redirect_pipenv_pylock_unsupported"));
}

/// A leftover `Pipfile` beside a governing uv / Poetry / PDM lock does not
/// make Pipenv the installer: the pylock is pinned as before, and no Pipenv
/// refusal vetoes the sibling lock's confirmation.
#[test]
fn pylock_beside_a_governing_tool_lock_is_not_pipenvs() {
let uv_lock = format!(
"version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {{ registry = \"https://pypi.org/simple\" }}\nwheels = [{{ url = \"https://files.pythonhosted.org/packages/d9/5a/{WHEEL}\", hash = \"sha256:{HEX}\" }}]\n"
);
for (lock, text) in [
("uv.lock", uv_lock),
("poetry.lock", String::new()),
("pdm.lock", String::new()),
] {
let result = rewrite(&files(&[
("Pipfile", PIPFILE.into()),
(lock, text),
("pylock.toml", pylock()),
]));
assert!(
result
.warnings
.iter()
.all(|w| w.code != "redirect_pipenv_pylock_unsupported"),
"{lock}: {:?}",
result.warnings
);
// Only the real uv.lock fixture pins cleanly; the empty Poetry /
// PDM stubs are refused by their own rewriters.
if lock != "uv.lock" {
continue;
}
assert!(
!result.refused_python_lock_uuids.contains(UUID),
"{lock}: {:?}",
result.warnings
);
assert!(
result
.files
.get("pylock.toml")
.is_some_and(|t| t.contains(WHEEL) && t.contains("archive")),
"{lock}: {:?}",
result.warnings
);
}
}
Loading
Loading