Skip to content

Commit a4e8f20

Browse files
authored
Support language_version: system (#949)
* . * Support `language_version: system` * Fix python downloads
1 parent b863270 commit a4e8f20

11 files changed

Lines changed: 188 additions & 54 deletions

File tree

lib/constants/src/env_vars.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ impl EnvVars {
2525
pub const PREK_INTERNAL__SKIP_POST_CHECKOUT: &'static str = "PREK_INTERNAL__SKIP_POST_CHECKOUT";
2626
pub const PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT: &'static str =
2727
"PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT";
28+
pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = "PREK_INTERNAL__GO_BINARY_NAME";
29+
pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = "PREK_INTERNAL__NODE_BINARY_NAME";
2830
pub const PREK_GENERATE: &'static str = "PREK_GENERATE";
2931

3032
// UV related

src/languages/golang/golang.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ impl LanguageImpl for Golang {
3232
let go_dir = store.tools_path(crate::store::ToolBucket::Go);
3333
let installer = GoInstaller::new(go_dir);
3434

35-
let version = match &hook.language_request {
36-
LanguageRequest::Any => &GoRequest::Any,
37-
LanguageRequest::Golang(version) => version,
35+
let (version, allows_download) = match &hook.language_request {
36+
LanguageRequest::Any { system_only } => (&GoRequest::Any, !system_only),
37+
LanguageRequest::Golang(version) => (version, true),
3838
_ => unreachable!(),
3939
};
4040
let go = installer
41-
.install(store, version)
41+
.install(store, version, allows_download)
4242
.await
4343
.context("Failed to install go")?;
4444

src/languages/golang/installer.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ use std::env::consts::EXE_EXTENSION;
22
use std::fmt::Display;
33
use std::path::{Path, PathBuf};
44
use std::str::FromStr;
5+
use std::sync::LazyLock;
56

67
use anyhow::{Context, Result};
8+
use constants::env_vars::EnvVars;
79
use itertools::Itertools;
810
use reqwest::Client;
911
use target_lexicon::{Architecture, HOST, OperatingSystem};
@@ -31,6 +33,15 @@ impl Display for GoResult {
3133
}
3234
}
3335

36+
/// Override the Go binary name for testing.
37+
static GO_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
38+
if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__GO_BINARY_NAME) {
39+
name
40+
} else {
41+
"go".to_string()
42+
}
43+
});
44+
3445
impl GoResult {
3546
fn from_executable(path: PathBuf, from_system: bool) -> Self {
3647
Self {
@@ -100,7 +111,12 @@ impl GoInstaller {
100111
}
101112
}
102113

103-
pub(crate) async fn install(&self, store: &Store, request: &GoRequest) -> Result<GoResult> {
114+
pub(crate) async fn install(
115+
&self,
116+
store: &Store,
117+
request: &GoRequest,
118+
allows_download: bool,
119+
) -> Result<GoResult> {
104120
fs_err::tokio::create_dir_all(&self.root).await?;
105121

106122
let _lock = LockedFile::acquire(self.root.join(".lock"), "go").await?;
@@ -115,6 +131,10 @@ impl GoInstaller {
115131
return Ok(go);
116132
}
117133

134+
if !allows_download {
135+
anyhow::bail!("No suitable system Go version found and downloads are disabled");
136+
}
137+
118138
let resolved_version = self
119139
.resolve_version(request)
120140
.await
@@ -231,7 +251,7 @@ impl GoInstaller {
231251
}
232252

233253
async fn find_system_go(&self, go_request: &GoRequest) -> Result<Option<GoResult>> {
234-
let go_paths = match which::which_all("go") {
254+
let go_paths = match which::which_all(&*GO_BINARY_NAME) {
235255
Ok(paths) => paths,
236256
Err(e) => {
237257
debug!("No go executables found in PATH: {}", e);

src/languages/node/installer.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ use std::fmt::Display;
33
use std::path::{Path, PathBuf};
44
use std::str::FromStr;
55
use std::string::ToString;
6+
use std::sync::LazyLock;
67

78
use anyhow::{Context, Result};
9+
use constants::env_vars::EnvVars;
810
use itertools::Itertools;
911
use reqwest::Client;
1012
use target_lexicon::{Architecture, HOST, OperatingSystem};
@@ -31,6 +33,15 @@ impl Display for NodeResult {
3133
}
3234
}
3335

36+
/// Override the Node binary name for testing.
37+
static NODE_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
38+
if let Ok(name) = EnvVars::var(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME) {
39+
name
40+
} else {
41+
"node".to_string()
42+
}
43+
});
44+
3445
impl NodeResult {
3546
pub(crate) fn from_executables(node: PathBuf, npm: PathBuf) -> Self {
3647
Self {
@@ -97,7 +108,12 @@ impl NodeInstaller {
97108
}
98109

99110
/// Install a version of Node.js.
100-
pub(crate) async fn install(&self, store: &Store, request: &NodeRequest) -> Result<NodeResult> {
111+
pub(crate) async fn install(
112+
&self,
113+
store: &Store,
114+
request: &NodeRequest,
115+
allows_download: bool,
116+
) -> Result<NodeResult> {
101117
fs_err::tokio::create_dir_all(&self.root).await?;
102118

103119
let _lock = LockedFile::acquire(self.root.join(".lock"), "node").await?;
@@ -113,6 +129,10 @@ impl NodeInstaller {
113129
return Ok(node_result);
114130
}
115131

132+
if !allows_download {
133+
anyhow::bail!("No suitable system Node version found and downloads are disabled");
134+
}
135+
116136
let resolved_version = self.resolve_version(request).await?;
117137
trace!(version = %resolved_version, "Downloading node");
118138

@@ -222,7 +242,7 @@ impl NodeInstaller {
222242

223243
/// Find a suitable system Node.js installation that matches the request.
224244
async fn find_system_node(&self, node_request: &NodeRequest) -> Result<Option<NodeResult>> {
225-
let node_paths = match which::which_all("node") {
245+
let node_paths = match which::which_all(&*NODE_BINARY_NAME) {
226246
Ok(paths) => paths,
227247
Err(e) => {
228248
debug!("No node executables found in PATH: {}", e);

src/languages/node/node.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ impl LanguageImpl for Node {
4343
let node_dir = store.tools_path(ToolBucket::Node);
4444
let installer = NodeInstaller::new(node_dir);
4545

46-
let node_request = match &hook.language_request {
47-
LanguageRequest::Any => &NodeRequest::Any,
48-
LanguageRequest::Node(node_request) => node_request,
46+
let (node_request, allows_download) = match &hook.language_request {
47+
LanguageRequest::Any { system_only } => (&NodeRequest::Any, !system_only),
48+
LanguageRequest::Node(node_request) => (node_request, true),
4949
_ => unreachable!(),
5050
};
5151
let node = installer
52-
.install(store, node_request)
52+
.install(store, node_request, allows_download)
5353
.await
5454
.context("Failed to install node")?;
5555

src/languages/python/python.rs

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,6 @@ pub(crate) async fn query_python_info(python: &Path) -> Result<PythonInfo> {
6363
})
6464
}
6565

66-
fn to_uv_python_request(request: &LanguageRequest) -> Option<String> {
67-
match request {
68-
LanguageRequest::Any => None,
69-
LanguageRequest::Python(request) => match request {
70-
PythonRequest::Any => None,
71-
PythonRequest::Major(major) => Some(format!("{major}")),
72-
PythonRequest::MajorMinor(major, minor) => Some(format!("{major}.{minor}")),
73-
PythonRequest::MajorMinorPatch(major, minor, patch) => {
74-
Some(format!("{major}.{minor}.{patch}"))
75-
}
76-
PythonRequest::Range(_, raw) => Some(raw.clone()),
77-
PythonRequest::Path(path) => Some(path.to_string_lossy().to_string()),
78-
},
79-
_ => unreachable!(),
80-
}
81-
}
82-
8366
impl LanguageImpl for Python {
8467
async fn install(
8568
&self,
@@ -102,10 +85,8 @@ impl LanguageImpl for Python {
10285

10386
debug!(%hook, target = %info.env_path.display(), "Installing environment");
10487

105-
let python_request = to_uv_python_request(&hook.language_request);
106-
10788
// Create venv (auto download Python if needed)
108-
Self::create_venv_with_retry(&uv, store, &info, python_request.as_ref())
89+
Self::create_venv(&uv, store, &info, &hook.language_request)
10990
.await
11091
.context("Failed to create Python virtual environment")?;
11192

@@ -223,12 +204,29 @@ impl LanguageImpl for Python {
223204
}
224205
}
225206

207+
fn to_uv_python_request(request: &LanguageRequest) -> Option<String> {
208+
match request {
209+
LanguageRequest::Any { .. } => None,
210+
LanguageRequest::Python(request) => match request {
211+
PythonRequest::Any => None,
212+
PythonRequest::Major(major) => Some(format!("{major}")),
213+
PythonRequest::MajorMinor(major, minor) => Some(format!("{major}.{minor}")),
214+
PythonRequest::MajorMinorPatch(major, minor, patch) => {
215+
Some(format!("{major}.{minor}.{patch}"))
216+
}
217+
PythonRequest::Range(_, raw) => Some(raw.clone()),
218+
PythonRequest::Path(path) => Some(path.to_string_lossy().to_string()),
219+
},
220+
_ => unreachable!(),
221+
}
222+
}
223+
226224
impl Python {
227-
async fn create_venv_with_retry(
225+
async fn create_venv(
228226
uv: &Uv,
229227
store: &Store,
230228
info: &InstallInfo,
231-
python_request: Option<&String>,
229+
python_request: &LanguageRequest,
232230
) -> Result<()> {
233231
// Try creating venv without downloads first
234232
match Self::create_venv_command(uv, store, info, python_request, false, false)
@@ -246,6 +244,12 @@ impl Python {
246244
Err(e @ process::Error::Status { .. }) => {
247245
// Check if we can retry with downloads
248246
if Self::can_retry_with_downloads(&e) {
247+
if !python_request.allows_download() {
248+
anyhow::bail!(
249+
"No suitable system Python version found and downloads are disabled"
250+
);
251+
}
252+
249253
debug!(
250254
"Retrying venv creation with managed Python downloads: `{}`",
251255
info.env_path.display()
@@ -270,7 +274,7 @@ impl Python {
270274
uv: &Uv,
271275
store: &Store,
272276
info: &InstallInfo,
273-
python_request: Option<&String>,
277+
python_request: &LanguageRequest,
274278
set_install_dir: bool,
275279
allow_downloads: bool,
276280
) -> Cmd {
@@ -297,7 +301,7 @@ impl Python {
297301
cmd.arg("--no-python-downloads");
298302
}
299303

300-
if let Some(python) = python_request {
304+
if let Some(python) = to_uv_python_request(python_request) {
301305
cmd.arg("--python").arg(python);
302306
}
303307

src/languages/python/version.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::str::FromStr;
55

66
use crate::hook::InstallInfo;
77
use crate::languages::version;
8-
use crate::languages::version::try_into_u64_slice;
8+
use crate::languages::version::{Error, try_into_u64_slice};
99

1010
#[derive(Debug, Clone, PartialEq, Eq)]
1111
pub(crate) enum PythonRequest {
@@ -53,15 +53,15 @@ impl FromStr for PythonRequest {
5353
// Try to parse as a VersionReq (like ">= 3.12" or ">=3.8, <3.12")
5454
semver::VersionReq::parse(request)
5555
.map(|version_req| PythonRequest::Range(version_req, request.into()))
56-
.map_err(|_| version::Error::InvalidVersion(request.to_string()))
56+
.map_err(|_| Error::InvalidVersion(request.to_string()))
5757
})
5858
.or_else(|_| {
5959
// If it doesn't match any known format, treat it as a path
6060
let path = PathBuf::from(request);
6161
if path.exists() {
6262
Ok(PythonRequest::Path(path))
6363
} else {
64-
Err(version::Error::InvalidVersion(request.to_string()))
64+
Err(Error::InvalidVersion(request.to_string()))
6565
}
6666
})
6767
}
@@ -77,16 +77,16 @@ impl PythonRequest {
7777
fn parse_version_numbers(
7878
version_str: &str,
7979
original_request: &str,
80-
) -> Result<PythonRequest, version::Error> {
80+
) -> Result<PythonRequest, Error> {
8181
let parts = try_into_u64_slice(version_str)
82-
.map_err(|_| version::Error::InvalidVersion(original_request.to_string()))?;
82+
.map_err(|_| Error::InvalidVersion(original_request.to_string()))?;
8383
let parts = split_wheel_tag_version(parts);
8484

8585
match parts[..] {
8686
[major] => Ok(PythonRequest::Major(major)),
8787
[major, minor] => Ok(PythonRequest::MajorMinor(major, minor)),
8888
[major, minor, patch] => Ok(PythonRequest::MajorMinorPatch(major, minor, patch)),
89-
_ => Err(version::Error::InvalidVersion(original_request.to_string())),
89+
_ => Err(Error::InvalidVersion(original_request.to_string())),
9090
}
9191
}
9292

src/languages/version.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub(crate) enum Error {
1414

1515
#[derive(Debug, Clone, Eq, PartialEq)]
1616
pub(crate) enum LanguageRequest {
17-
Any,
17+
Any { system_only: bool },
1818
Python(PythonRequest),
1919
Node(NodeRequest),
2020
Golang(GoRequest),
@@ -25,14 +25,26 @@ pub(crate) enum LanguageRequest {
2525
impl LanguageRequest {
2626
pub(crate) fn is_any(&self) -> bool {
2727
match self {
28-
LanguageRequest::Any => true,
28+
LanguageRequest::Any { .. } => true,
2929
LanguageRequest::Python(req) => req.is_any(),
3030
LanguageRequest::Node(req) => req.is_any(),
3131
LanguageRequest::Golang(req) => req.is_any(),
3232
LanguageRequest::Semver(_) => false,
3333
}
3434
}
3535

36+
/// Returns true if this request allows downloading a version.
37+
///
38+
/// Currently, only `system` disallows downloading. In the future,
39+
/// we may add more specific version requests that also disallow downloading.
40+
/// For example `language_version: 3.12; system_only`.
41+
pub(crate) fn allows_download(&self) -> bool {
42+
match self {
43+
LanguageRequest::Any { system_only } => !system_only,
44+
_ => true,
45+
}
46+
}
47+
3648
pub(crate) fn parse(lang: Language, request: &str) -> Result<Self, Error> {
3749
// `pre-commit` support these values in `language_version`:
3850
// - `default`: substituted by language `get_default_version` function
@@ -44,9 +56,11 @@ impl LanguageRequest {
4456
// - Node.js version passed down to `nodeenv`
4557
// - Rust version passed down to `rustup`
4658

47-
// TODO: support `system`? Does anyone use it?
4859
if request == "default" || request.is_empty() {
49-
return Ok(LanguageRequest::Any);
60+
return Ok(LanguageRequest::Any { system_only: false });
61+
}
62+
if request == "system" {
63+
return Ok(LanguageRequest::Any { system_only: true });
5064
}
5165

5266
Ok(match lang {
@@ -59,7 +73,7 @@ impl LanguageRequest {
5973

6074
pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool {
6175
match self {
62-
LanguageRequest::Any => true,
76+
LanguageRequest::Any { .. } => true,
6377
LanguageRequest::Python(req) => req.satisfied_by(install_info),
6478
LanguageRequest::Node(req) => req.satisfied_by(install_info),
6579
LanguageRequest::Golang(req) => req.satisfied_by(install_info),

tests/common/mod.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -390,18 +390,17 @@ macro_rules! cmd_snapshot {
390390
pub(crate) use cmd_snapshot;
391391

392392
#[allow(clippy::disallowed_methods)]
393-
pub(crate) fn remove_bin_from_path(bin: &str) -> anyhow::Result<OsString> {
393+
pub(crate) fn remove_bin_from_path(bin: &str, path: Option<OsString>) -> anyhow::Result<OsString> {
394+
let path = path.unwrap_or(std::env::var_os("PATH").expect("PATH not set"));
394395
let Ok(dirs) = which::which_all(bin) else {
395-
return Ok(std::env::var_os("PATH").expect("PATH environment variable is not set"));
396+
return Ok(path);
396397
};
397398

398399
let dirs: FxHashSet<_> = dirs
399400
.filter_map(|path| path.parent().map(Path::to_path_buf))
400401
.collect();
401402

402-
let current_path = std::env::var("PATH").unwrap_or_default();
403-
404-
let new_path_entries: Vec<_> = std::env::split_paths(&current_path)
403+
let new_path_entries: Vec<_> = std::env::split_paths(&path)
405404
.filter(|path| !dirs.contains(path.as_path()))
406405
.collect();
407406

0 commit comments

Comments
 (0)