Skip to content

Commit e733a87

Browse files
committed
Teach ty check to ask uv to sync the venv of a PEP-723 script
1 parent 0ab8521 commit e733a87

File tree

4 files changed

+261
-6
lines changed

4 files changed

+261
-6
lines changed

crates/ruff_python_ast/src/script.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"))
1212
#[derive(Debug, Clone, Eq, PartialEq)]
1313
pub struct ScriptTag {
1414
/// The content of the script before the metadata block.
15-
prelude: String,
15+
pub prelude: String,
1616
/// The metadata block.
17-
metadata: String,
17+
pub metadata: String,
1818
/// The content of the script after the metadata block.
19-
postlude: String,
19+
pub postlude: String,
2020
}
2121

2222
impl ScriptTag {

crates/ty/src/lib.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
9090
})?
9191
};
9292

93+
let system = OsSystem::new(&cwd);
94+
95+
// If we see a single path, check if it's a PEP-723 script
96+
let mut script_project = None;
97+
if let [path] = &*args.paths {
98+
match ProjectMetadata::discover_script(path, &system) {
99+
Ok(project) => {
100+
script_project = Some(project);
101+
}
102+
Err(ty_project::ProjectMetadataError::NotAScript(_)) => {
103+
// This is fine
104+
}
105+
Err(e) => tracing::info!("Issue reading script at `{path}`: {e}"),
106+
}
107+
}
108+
93109
let project_path = args
94110
.project
95111
.as_ref()
@@ -111,7 +127,6 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
111127
.map(|path| SystemPath::absolute(path, &cwd))
112128
.collect();
113129

114-
let system = OsSystem::new(&cwd);
115130
let watch = args.watch;
116131
let exit_zero = args.exit_zero;
117132
let config_file = args
@@ -121,7 +136,13 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
121136

122137
let mut project_metadata = match &config_file {
123138
Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
124-
None => ProjectMetadata::discover(&project_path, &system)?,
139+
None => {
140+
if let Some(project) = script_project {
141+
project
142+
} else {
143+
ProjectMetadata::discover(&project_path, &system)?
144+
}
145+
}
125146
};
126147

127148
project_metadata.apply_configuration_files(&system)?;

crates/ty_project/src/metadata.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ use ty_python_semantic::ProgramSettings;
99

1010
use crate::metadata::options::ProjectOptionsOverrides;
1111
use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError};
12-
use crate::metadata::value::ValueSource;
12+
use crate::metadata::script::{Pep723Error, Pep723Metadata};
13+
use crate::metadata::value::{RelativePathBuf, ValueSource};
1314
pub use options::Options;
1415
use options::TyTomlError;
1516

1617
mod configuration_file;
1718
pub mod options;
1819
pub mod pyproject;
20+
pub mod script;
1921
pub mod settings;
2022
pub mod value;
2123

@@ -85,6 +87,32 @@ impl ProjectMetadata {
8587
)
8688
}
8789

90+
/// Loads a project from a `pyproject.toml` file.
91+
pub(crate) fn from_script(
92+
script: Pep723Metadata,
93+
script_path: &SystemPath,
94+
) -> Result<Self, ResolveRequiresPythonError> {
95+
let project = Some(&script.to_project());
96+
let parent_dir = script_path
97+
.parent()
98+
.map(ToOwned::to_owned)
99+
.unwrap_or_default();
100+
let mut metadata = Self::from_options(
101+
script.tool.and_then(|tool| tool.ty).unwrap_or_default(),
102+
parent_dir,
103+
project,
104+
)?;
105+
106+
// Try to get `uv sync --script` to setup the venv for us
107+
if let Some(python) = script::uv_sync_script(script_path) {
108+
let mut environment = metadata.options.environment.unwrap_or_default();
109+
environment.python = Some(RelativePathBuf::new(python, ValueSource::Cli));
110+
metadata.options.environment = Some(environment);
111+
}
112+
113+
Ok(metadata)
114+
}
115+
88116
/// Loads a project from a set of options with an optional pyproject-project table.
89117
pub fn from_options(
90118
mut options: Options,
@@ -120,6 +148,46 @@ impl ProjectMetadata {
120148
})
121149
}
122150

151+
pub fn discover_script(
152+
path: &SystemPath,
153+
system: &dyn System,
154+
) -> Result<ProjectMetadata, ProjectMetadataError> {
155+
tracing::debug!("Searching for a PEP-723 Script in '{path}'");
156+
if !system.is_file(path) {
157+
return Err(ProjectMetadataError::NotAScript(path.to_path_buf()));
158+
}
159+
160+
let script_metadata = if let Ok(script_str) = system.read_to_string(path) {
161+
match Pep723Metadata::from_script_str(
162+
script_str.as_bytes(),
163+
ValueSource::File(Arc::new(path.to_owned())),
164+
) {
165+
Ok(Some(pyproject)) => Some(pyproject),
166+
Ok(None) => None,
167+
Err(error) => {
168+
return Err(ProjectMetadataError::InvalidScript {
169+
path: path.to_owned(),
170+
source: Box::new(error),
171+
});
172+
}
173+
}
174+
} else {
175+
None
176+
};
177+
178+
let Some(script_metadata) = script_metadata else {
179+
return Err(ProjectMetadataError::NotAScript(path.to_path_buf()));
180+
};
181+
182+
let metadata = ProjectMetadata::from_script(script_metadata, path).map_err(|err| {
183+
ProjectMetadataError::InvalidRequiresPythonConstraint {
184+
source: err,
185+
path: path.to_owned(),
186+
}
187+
})?;
188+
189+
Ok(metadata)
190+
}
123191
/// Discovers the closest project at `path` and returns its metadata.
124192
///
125193
/// The algorithm traverses upwards in the `path`'s ancestor chain and uses the following precedence
@@ -319,12 +387,21 @@ pub enum ProjectMetadataError {
319387
#[error("project path '{0}' is not a directory")]
320388
NotADirectory(SystemPathBuf),
321389

390+
#[error("project path '{0}' is not a PEP-723 script")]
391+
NotAScript(SystemPathBuf),
392+
322393
#[error("{path} is not a valid `pyproject.toml`: {source}")]
323394
InvalidPyProject {
324395
source: Box<PyProjectError>,
325396
path: SystemPathBuf,
326397
},
327398

399+
#[error("{path} is not a valid PEP-723 script: {source}")]
400+
InvalidScript {
401+
source: Box<Pep723Error>,
402+
path: SystemPathBuf,
403+
},
404+
328405
#[error("{path} is not a valid `ty.toml`: {source}")]
329406
InvalidTyToml {
330407
source: Box<TyTomlError>,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use std::{io, process::Command, str::FromStr};
2+
3+
use camino::Utf8PathBuf;
4+
use pep440_rs::VersionSpecifiers;
5+
use ruff_db::system::{SystemPath, SystemPathBuf};
6+
use ruff_python_ast::script::ScriptTag;
7+
use serde::Deserialize;
8+
use thiserror::Error;
9+
10+
use crate::metadata::{
11+
pyproject::{Project, Tool},
12+
value::{RangedValue, ValueSource, ValueSourceGuard},
13+
};
14+
15+
/// PEP 723 metadata as parsed from a `script` comment block.
16+
///
17+
/// See: <https://peps.python.org/pep-0723/>
18+
#[derive(Debug, Deserialize, Clone)]
19+
#[serde(rename_all = "kebab-case")]
20+
pub struct Pep723Metadata {
21+
pub dependencies: Option<RangedValue<Vec<toml::Value>>>,
22+
pub requires_python: Option<RangedValue<VersionSpecifiers>>,
23+
pub tool: Option<Tool>,
24+
25+
/// The raw unserialized document.
26+
#[serde(skip)]
27+
pub raw: String,
28+
}
29+
30+
#[derive(Debug, Error)]
31+
pub enum Pep723Error {
32+
#[error(
33+
"An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`."
34+
)]
35+
UnclosedBlock,
36+
#[error("The PEP 723 metadata block is missing from the script.")]
37+
MissingTag,
38+
#[error(transparent)]
39+
Io(#[from] io::Error),
40+
#[error(transparent)]
41+
Utf8(#[from] std::str::Utf8Error),
42+
#[error(transparent)]
43+
Toml(#[from] toml::de::Error),
44+
#[error("Invalid filename `{0}` supplied")]
45+
InvalidFilename(String),
46+
}
47+
48+
impl Pep723Metadata {
49+
/// Parse the PEP 723 metadata from `stdin`.
50+
pub fn from_script_str(
51+
contents: &[u8],
52+
source: ValueSource,
53+
) -> Result<Option<Self>, Pep723Error> {
54+
let _guard = ValueSourceGuard::new(source, true);
55+
56+
// Extract the `script` tag.
57+
let Some(ScriptTag { metadata, .. }) = ScriptTag::parse(contents) else {
58+
return Ok(None);
59+
};
60+
61+
// Parse the metadata.
62+
Ok(Some(Self::from_str(&metadata)?))
63+
}
64+
65+
pub fn to_project(&self) -> Project {
66+
Project {
67+
name: None,
68+
version: None,
69+
requires_python: self.requires_python.clone(),
70+
}
71+
}
72+
}
73+
74+
/*
75+
{
76+
"schema": {
77+
"version": "preview"
78+
},
79+
"target": "script",
80+
"script": {
81+
"path": "/Users/myuser/code/myproj/scripts/load-test.py"
82+
},
83+
"sync": {
84+
"environment": {
85+
"path": "/Users/myuser/.cache/uv/environments-v2/load-test-d6edaf5bfab110a8",
86+
"python": {
87+
"path": "/Users/myuser/.cache/uv/environments-v2/load-test-d6edaf5bfab110a8/bin/python3",
88+
"version": "3.14.0",
89+
"implementation": "cpython"
90+
}
91+
},
92+
"action": "check"
93+
},
94+
"lock": null,
95+
"dry_run": false
96+
}
97+
*/
98+
99+
/// The output of `uv sync --output-format=json --script ...`
100+
#[derive(Debug, Clone, Deserialize)]
101+
struct UvMetadata {
102+
sync: Option<UvSync>,
103+
}
104+
105+
#[derive(Debug, Clone, Deserialize)]
106+
struct UvSync {
107+
environment: Option<UvEnvironment>,
108+
}
109+
110+
#[derive(Debug, Clone, Deserialize)]
111+
struct UvEnvironment {
112+
path: Option<String>,
113+
}
114+
115+
/// Ask `uv` to sync the script's venv to some temp dir so we can analyze dependencies properly
116+
///
117+
/// Returns the path to the venv on success
118+
pub fn uv_sync_script(script_path: &SystemPath) -> Option<SystemPathBuf> {
119+
tracing::info!("Asking uv to sync the script's venv");
120+
let mut command = Command::new("uv");
121+
command
122+
.arg("sync")
123+
.arg("--output-format=json")
124+
.arg("--script")
125+
.arg(script_path.as_str());
126+
let output = command
127+
.output()
128+
.inspect_err(|e| {
129+
tracing::info!(
130+
"failed to run `uv sync --output-format=json --script {script_path}`: {e}"
131+
);
132+
})
133+
.ok()?;
134+
let metadata: UvMetadata = serde_json::from_slice(&output.stdout)
135+
.inspect_err(|e| {
136+
tracing::info!(
137+
"failed to parse `uv sync --output-format=json --script {script_path}`: {e}"
138+
);
139+
})
140+
.ok()?;
141+
let env_path = metadata.sync?.environment?.path?;
142+
let utf8_path = Utf8PathBuf::from(env_path);
143+
Some(SystemPathBuf::from_utf8_path_buf(utf8_path))
144+
}
145+
146+
impl FromStr for Pep723Metadata {
147+
type Err = toml::de::Error;
148+
149+
/// Parse `Pep723Metadata` from a raw TOML string.
150+
fn from_str(raw: &str) -> Result<Self, Self::Err> {
151+
let metadata = toml::from_str(raw)?;
152+
Ok(Self {
153+
raw: raw.to_string(),
154+
..metadata
155+
})
156+
}
157+
}

0 commit comments

Comments
 (0)