Skip to content
Open
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
9 changes: 9 additions & 0 deletions crates/pyrefly_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,11 @@ pub struct ConfigFile {
)]
pub use_ignore_files: bool,

/// Whether to check each PEP 723 script in the Python environment that
/// `uv workspace metadata --script` reports for it.
#[serde(default, skip_serializing_if = "crate::util::skip_default_false")]
pub use_uv: bool,

/// Should this config use a build system? If so, which one?
pub build_system: Option<BuildSystem>,

Expand Down Expand Up @@ -814,6 +819,7 @@ impl Default for ConfigFile {
source_db: Default::default(),
target_configs: ArcId::new(RwLock::new(SmallMap::new())),
use_ignore_files: true,
use_uv: false,
typeshed_path: None,
baseline: None,
baseline_error_level: None,
Expand Down Expand Up @@ -2597,6 +2603,7 @@ mod tests {
preset: None,
build_system: Default::default(),
use_ignore_files: true,
use_uv: false,
output_format: Some(OutputFormat::MinText),
fallback_search_path: Default::default(),
python_environment: PythonEnvironment {
Expand Down Expand Up @@ -3006,6 +3013,7 @@ mod tests {
import_root: None,
preset: None,
use_ignore_files: true,
use_uv: false,
output_format: Some(OutputFormat::Json),
fallback_search_path: Default::default(),
python_environment: python_environment.clone(),
Expand Down Expand Up @@ -3093,6 +3101,7 @@ mod tests {
enable_fallback_search_path: false,
disable_project_excludes_heuristics: false,
use_ignore_files: true,
use_uv: false,
output_format: Some(OutputFormat::Json),
import_root: None,
preset: None,
Expand Down
1 change: 1 addition & 0 deletions crates/pyrefly_config/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ pub mod environment;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod interpreter_query;
pub mod interpreters;
pub mod uv;
pub(crate) mod venv;
9 changes: 9 additions & 0 deletions crates/pyrefly_config/src/environment/interpreters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ impl Interpreters {
self.python_interpreter_path = Some(ConfigOrigin::lsp(interpreter));
}

/// Replaces every interpreter selection with `interpreter`, which Pyrefly found for the
/// file that this config applies to.
pub fn select_python_interpreter(&mut self, interpreter: PathBuf) {
*self = Self {
python_interpreter_path: Some(ConfigOrigin::auto(interpreter)),
..Self::default()
};
}

/// Finds interpreters by searching in prioritized locations for the given project
/// and interpreter settings.
///
Expand Down
115 changes: 115 additions & 0 deletions crates/pyrefly_config/src/environment/uv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

//! Python environments of PEP 723 scripts, read from `uv workspace metadata`.

use std::path::Path;
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
use std::process::Command;

#[cfg(not(target_arch = "wasm32"))]
use anyhow::Context as _;
use serde::Deserialize;

/// The subset of `uv workspace metadata` output that names a script's environment.
#[derive(Deserialize)]
struct Metadata {
environment: Option<Environment>,
}

#[derive(Deserialize)]
struct Environment {
python: Python,
}

#[derive(Deserialize)]
struct Python {
path: PathBuf,
}

/// Returns the contents of the PEP 723 `script` metadata block in `source`, if it has one.
pub fn script_metadata(source: &str) -> Option<&str> {
let mut start = None;
let mut offset = 0;
for line in source.split_inclusive('\n') {
let text = line.trim_end_matches(['\n', '\r']);
match start {
None if text == "# /// script" => start = Some(offset + line.len()),
Some(start) if text == "# ///" => return Some(&source[start..offset]),
Some(_) if text != "#" && !text.starts_with("# ") => start = None,
_ => {}
}
offset += line.len();
}
None
}

/// Returns the interpreter of the environment that uv runs `script` in, or `None` if that
/// environment does not exist. This never creates or updates the environment.
///
/// The uv executable is `$UV`, which uv sets for the commands that it runs, or `uv` on `PATH`.
#[cfg(not(target_arch = "wasm32"))]
pub fn script_interpreter(script: &Path) -> anyhow::Result<Option<PathBuf>> {
let mut command = Command::new(std::env::var_os("UV").unwrap_or_else(|| "uv".into()));
command
.args([
"workspace",
"metadata",
"--preview-features",
"workspace-metadata",
"--script",
])
.arg(script);
if let Some(directory) = script.parent() {
command.current_dir(directory);
}
let output = command
.output()
.context("Failed to run `uv workspace metadata`")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"`uv workspace metadata --script {}` failed: {}",
script.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let metadata: Metadata = serde_json::from_slice(&output.stdout)
.context("Failed to parse `uv workspace metadata` output")?;
Ok(metadata
.environment
.map(|environment| environment.python.path))
}

#[cfg(target_arch = "wasm32")]
pub fn script_interpreter(_script: &Path) -> anyhow::Result<Option<PathBuf>> {
Err(anyhow::anyhow!(
"uv script environments are not supported on WebAssembly"
))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_script_metadata() {
assert_eq!(
script_metadata(
"#!/usr/bin/env python\n# /// script\n# dependencies = [\"six\"]\n#\n# ///\nimport six\n"
),
Some("# dependencies = [\"six\"]\n#\n"),
);
assert_eq!(
script_metadata("# /// script\r\n# requires-python = \">=3.12\"\r\n# ///\r\n"),
Some("# requires-python = \">=3.12\"\r\n"),
);
assert_eq!(script_metadata("# /// script\nimport six\n# ///\n"), None);
assert_eq!(script_metadata("# /// pyproject\n# ///\n"), None);
assert_eq!(script_metadata("x = 1\n"), None);
}
}
24 changes: 21 additions & 3 deletions crates/pyrefly_config/src/finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ type LoadCallback = Box<dyn Fn(&Path) -> (ArcId<ConfigFile>, Vec<ConfigError>) +
type FallbackCallback =
Box<dyn Fn(ModuleNameWithKind, &ModulePath) -> ArcId<ConfigFile> + Send + Sync>;

/// Function that replaces the config found for a Python file with one for that file alone, such
/// as a PEP 723 script that runs in its own environment.
pub type FileCallback =
Box<dyn Fn(ArcId<ConfigFile>, &ModulePath) -> ArcId<ConfigFile> + Send + Sync>;

/// A way to find a config file given a directory or Python file.
/// Uses a lot of caching.
pub struct ConfigFinder {
Expand All @@ -115,6 +120,8 @@ pub struct ConfigFinder {
before: BeforeCallback,
/// If there is no config file, or loading it fails, use this fallback.
fallback: FallbackCallback,
/// Applied to every config that [`Self::python_file`] returns.
file: FileCallback,
clear_extra_caches: Box<dyn Fn() + Send + Sync>,
}

Expand All @@ -129,12 +136,19 @@ impl ConfigFinder {
Box::new(|_, _| Ok(None)),
load,
fallback,
Box::new(|config, _| config),
clear_extra_caches,
)
}

/// Create a new ConfigFinder that always returns the same constant.
pub fn new_constant(constant: ArcId<ConfigFile>) -> Self {
Self::new_constant_with_file(constant, Box::new(|config, _| config))
}

/// Create a new ConfigFinder that returns the same constant, passed through `file` for each
/// Python file.
pub fn new_constant_with_file(constant: ArcId<ConfigFile>, file: FileCallback) -> Self {
let c1 = constant.dupe();
let c2 = constant.dupe();
let c3 = constant;
Expand All @@ -143,6 +157,7 @@ impl ConfigFinder {
Box::new(move |_, _| Ok(Some(c1.dupe()))),
Box::new(move |_| (c2.dupe(), Vec::new())),
Box::new(move |_, _| c3.dupe()),
file,
Box::new(|| {}),
)
}
Expand All @@ -154,6 +169,7 @@ impl ConfigFinder {
before: BeforeCallback,
load: LoadCallback,
fallback: FallbackCallback,
file: FileCallback,
clear_extra_caches: Box<dyn Fn() + Send + Sync>,
) -> Self {
let errors = Arc::new(Mutex::new(Vec::new()));
Expand Down Expand Up @@ -209,6 +225,7 @@ impl ConfigFinder {
errors,
before,
fallback,
file,
clear_extra_caches,
}
}
Expand Down Expand Up @@ -256,7 +273,7 @@ impl ConfigFinder {
pub fn python_file(&self, name: ModuleNameWithKind, path: &ModulePath) -> ArcId<ConfigFile> {
let module_name = name.name();
match (self.before)(module_name, path) {
Ok(Some(x)) => return x,
Ok(Some(x)) => return (self.file)(x, path),
Ok(None) => {}
Err(e) => {
self.errors.lock().push(ConfigError::error(e));
Expand All @@ -271,7 +288,7 @@ impl ConfigFinder {
None => (self.fallback)(name, path),
};

match path.details() {
let config = match path.details() {
ModulePathDetails::FileSystem(x) | ModulePathDetails::Memory(x) => {
let absolute = x.absolutize();
f(absolute.parent())
Expand All @@ -280,7 +297,8 @@ impl ConfigFinder {
ModulePathDetails::BundledTypeshed(_) => f(None),
ModulePathDetails::BundledTypeshedThirdParty(_) => f(None),
ModulePathDetails::BundledThirdParty(_) => f(None),
}
};
(self.file)(config, path)
}

/// If we have an error, print all the errors that the config finder has accumulated. This is used
Expand Down
Loading
Loading