Skip to content

Commit

Permalink
[red-knot] Anchor relative paths in configurations
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser committed Jan 21, 2025
1 parent 0c6339c commit 545afe8
Show file tree
Hide file tree
Showing 7 changed files with 371 additions and 43 deletions.
19 changes: 7 additions & 12 deletions crates/red_knot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use colored::Colorize;
use crossbeam::channel as crossbeam_channel;
use python_version::PythonVersion;
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
use red_knot_project::metadata::value::RelativePathBuf;
use red_knot_project::watch;
use red_knot_project::watch::ProjectWatcher;
use red_knot_project::{ProjectDatabase, ProjectMetadata};
Expand Down Expand Up @@ -69,22 +70,16 @@ struct Args {
}

impl Args {
fn to_options(&self, cli_cwd: &SystemPath) -> Options {
fn to_options(&self) -> Options {
Options {
environment: Some(EnvironmentOptions {
python_version: self.python_version.map(Into::into),
venv_path: self
.venv_path
.as_ref()
.map(|venv_path| SystemPath::absolute(venv_path, cli_cwd)),
typeshed: self
.typeshed
.as_ref()
.map(|typeshed| SystemPath::absolute(typeshed, cli_cwd)),
venv_path: self.venv_path.as_ref().map(RelativePathBuf::cli),
typeshed: self.typeshed.as_ref().map(RelativePathBuf::cli),
extra_paths: self.extra_search_path.as_ref().map(|extra_search_paths| {
extra_search_paths
.iter()
.map(|path| SystemPath::absolute(path, cli_cwd))
.map(RelativePathBuf::cli)
.collect()
}),
..EnvironmentOptions::default()
Expand Down Expand Up @@ -158,8 +153,8 @@ fn run() -> anyhow::Result<ExitStatus> {
.transpose()?
.unwrap_or_else(|| cli_base_path.clone());

let system = OsSystem::new(cwd.clone());
let cli_options = args.to_options(&cwd);
let system = OsSystem::new(cwd);
let cli_options = args.to_options();
let mut workspace_metadata = ProjectMetadata::discover(system.current_directory(), &system)?;
workspace_metadata.apply_cli_options(cli_options.clone());

Expand Down
137 changes: 136 additions & 1 deletion crates/red_knot/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use tempfile::TempDir;
/// Specifying an option on the CLI should take precedence over the same setting in the
/// project's configuration.
#[test]
fn test_config_override() -> anyhow::Result<()> {
fn config_override() -> anyhow::Result<()> {
let tempdir = TempDir::new()?;

std::fs::write(
Expand Down Expand Up @@ -51,6 +51,141 @@ print(sys.last_exc)
Ok(())
}

/// Paths specified on the CLI are relative to the current working directory and not the project root.
///
/// We test this by adding an extra search path from the CLI to the libs directory when
/// running the CLI from the child directory (using relative paths).
///
/// Project layout:
/// ```
/// - libs
/// |- utils.py
/// - child
/// | - test.py
/// - pyproject.toml
/// ```
///
/// And the command is run in the `child` directory.
#[test]
fn cli_arguments_are_relative_to_the_current_directory() -> anyhow::Result<()> {
let tempdir = TempDir::new()?;

let project_dir = tempdir.path();

let libs = project_dir.join("libs");
std::fs::create_dir_all(&libs).context("Failed to create `libs` directory")?;

let child = project_dir.join("child");
std::fs::create_dir(&child).context("Failed to create `child` directory")?;

std::fs::write(
tempdir.path().join("pyproject.toml"),
r#"
[tool.knot.environment]
python-version = "3.11"
"#,
)
.context("Failed to write `pyproject.toml`")?;

std::fs::write(
libs.join("utils.py"),
r#"
def add(a: int, b: int) -> int:
a + b
"#,
)
.context("Failed to write `utils.py`")?;

std::fs::write(
child.join("test.py"),
r#"
from utils import add
stat = add(10, 15)
"#,
)
.context("Failed to write `child/test.py`")?;

insta::with_settings!({filters => vec![(&*tempdir_filter(&tempdir), "<temp_dir>/")]}, {
assert_cmd_snapshot!(knot().current_dir(child).arg("--extra-search-path").arg("../libs"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
});

Ok(())
}

/// Paths specified in a configuration file are relative to the project root.
///
/// We test this by adding `libs` (as a relative path) to the extra search path in the configuration and run
/// the CLI from a subdirectory.
///
/// Project layout:
/// ```
/// - libs
/// |- utils.py
/// - child
/// | - test.py
/// - pyproject.toml
/// ```
#[test]
fn paths_in_configuration_files_are_relative_to_the_project_root() -> anyhow::Result<()> {
let tempdir = TempDir::new()?;

let project_dir = tempdir.path();

let libs = project_dir.join("libs");
std::fs::create_dir_all(&libs).context("Failed to create `libs` directory")?;

let child = project_dir.join("child");
std::fs::create_dir(&child).context("Failed to create `child` directory")?;

std::fs::write(
tempdir.path().join("pyproject.toml"),
r#"
[tool.knot.environment]
python-version = "3.11"
extra-paths = ["libs"]
"#,
)
.context("Failed to write `pyproject.toml`")?;

std::fs::write(
libs.join("utils.py"),
r#"
def add(a: int, b: int) -> int:
a + b
"#,
)
.context("Failed to write `utils.py`")?;

std::fs::write(
child.join("test.py"),
r#"
from utils import add
stat = add(10, 15)
"#,
)
.context("Failed to write `child/test.py`")?;

insta::with_settings!({filters => vec![(&*tempdir_filter(&tempdir), "<temp_dir>/")]}, {
assert_cmd_snapshot!(knot().current_dir(child), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
});

Ok(())
}

fn knot() -> Command {
Command::new(get_cargo_bin("red_knot"))
}
Expand Down
15 changes: 9 additions & 6 deletions crates/red_knot/tests/file_watching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
use anyhow::{anyhow, Context};
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
use red_knot_project::metadata::pyproject::{PyProject, Tool};
use red_knot_project::metadata::value::RelativePathBuf;
use red_knot_project::watch::{directory_watcher, ChangeEvent, ProjectWatcher};
use red_knot_project::{Db, ProjectDatabase, ProjectMetadata};
use red_knot_python_semantic::{resolve_module, ModuleName, PythonPlatform, PythonVersion};
Expand Down Expand Up @@ -791,7 +792,7 @@ fn search_path() -> anyhow::Result<()> {
let mut case = setup_with_options([("bar.py", "import sub.a")], |root_path, _project_path| {
Some(Options {
environment: Some(EnvironmentOptions {
extra_paths: Some(vec![root_path.join("site_packages")]),
extra_paths: Some(vec![RelativePathBuf::cli(root_path.join("site_packages"))]),
..EnvironmentOptions::default()
}),
..Options::default()
Expand Down Expand Up @@ -832,7 +833,7 @@ fn add_search_path() -> anyhow::Result<()> {
// Register site-packages as a search path.
case.update_options(Options {
environment: Some(EnvironmentOptions {
extra_paths: Some(vec![site_packages.clone()]),
extra_paths: Some(vec![RelativePathBuf::cli("site_packages")]),
..EnvironmentOptions::default()
}),
..Options::default()
Expand All @@ -855,7 +856,7 @@ fn remove_search_path() -> anyhow::Result<()> {
let mut case = setup_with_options([("bar.py", "import sub.a")], |root_path, _project_path| {
Some(Options {
environment: Some(EnvironmentOptions {
extra_paths: Some(vec![root_path.join("site_packages")]),
extra_paths: Some(vec![RelativePathBuf::cli(root_path.join("site_packages"))]),
..EnvironmentOptions::default()
}),
..Options::default()
Expand Down Expand Up @@ -951,7 +952,7 @@ fn changed_versions_file() -> anyhow::Result<()> {
|root_path, _project_path| {
Some(Options {
environment: Some(EnvironmentOptions {
typeshed: Some(root_path.join("typeshed")),
typeshed: Some(RelativePathBuf::cli(root_path.join("typeshed"))),
..EnvironmentOptions::default()
}),
..Options::default()
Expand Down Expand Up @@ -1375,10 +1376,12 @@ mod unix {

Ok(())
},
|_root, project| {
|_root, _project| {
Some(Options {
environment: Some(EnvironmentOptions {
extra_paths: Some(vec![project.join(".venv/lib/python3.12/site-packages")]),
extra_paths: Some(vec![RelativePathBuf::cli(
".venv/lib/python3.12/site-packages",
)]),
python_version: Some(PythonVersion::PY312),
..EnvironmentOptions::default()
}),
Expand Down
13 changes: 11 additions & 2 deletions crates/red_knot_project/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
use red_knot_python_semantic::ProgramSettings;
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_python_ast::name::Name;
use std::sync::Arc;
use thiserror::Error;

use crate::combine::Combine;
use crate::metadata::pyproject::{Project, PyProject, PyProjectError};
use crate::metadata::value::ValueSource;
use options::KnotTomlError;
use options::Options;

pub mod options;
pub mod pyproject;
pub mod value;

#[derive(Debug, PartialEq, Eq)]
#[cfg_attr(test, derive(serde::Serialize))]
Expand Down Expand Up @@ -87,7 +90,10 @@ impl ProjectMetadata {
let pyproject_path = project_root.join("pyproject.toml");

let pyproject = if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
match PyProject::from_toml_str(&pyproject_str) {
match PyProject::from_toml_str(
&pyproject_str,
ValueSource::File(Arc::new(pyproject_path.clone())),
) {
Ok(pyproject) => Some(pyproject),
Err(error) => {
return Err(ProjectDiscoveryError::InvalidPyProject {
Expand All @@ -103,7 +109,10 @@ impl ProjectMetadata {
// A `knot.toml` takes precedence over a `pyproject.toml`.
let knot_toml_path = project_root.join("knot.toml");
if let Ok(knot_str) = system.read_to_string(&knot_toml_path) {
let options = match Options::from_toml_str(&knot_str) {
let options = match Options::from_toml_str(
&knot_str,
ValueSource::File(Arc::new(knot_toml_path.clone())),
) {
Ok(options) => options,
Err(error) => {
return Err(ProjectDiscoveryError::InvalidKnotToml {
Expand Down
50 changes: 29 additions & 21 deletions crates/red_knot_project/src/metadata/options.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::metadata::value::{RelativePathBuf, ValueSource, ValueSourceGuard};
use red_knot_python_semantic::{
ProgramSettings, PythonPlatform, PythonVersion, SearchPathSettings, SitePackages,
};
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_db::system::{System, SystemPath};
use ruff_macros::Combine;
use serde::{Deserialize, Serialize};
use thiserror::Error;
Expand All @@ -16,7 +17,8 @@ pub struct Options {
}

impl Options {
pub(crate) fn from_toml_str(content: &str) -> Result<Self, KnotTomlError> {
pub(crate) fn from_toml_str(content: &str, source: ValueSource) -> Result<Self, KnotTomlError> {
let _guard = ValueSourceGuard::new(source);
let options = toml::from_str(content)?;
Ok(options)
}
Expand Down Expand Up @@ -44,19 +46,19 @@ impl Options {
project_root: &SystemPath,
system: &dyn System,
) -> SearchPathSettings {
let src_roots =
if let Some(src_root) = self.src.as_ref().and_then(|src| src.root.as_deref()) {
vec![src_root.to_path_buf()]
let src_roots = if let Some(src_root) = self.src.as_ref().and_then(|src| src.root.as_ref())
{
vec![src_root.absolute(project_root, system)]
} else {
let src = project_root.join("src");

// Default to `src` and the project root if `src` exists and the root hasn't been specified.
if system.is_directory(&src) {
vec![project_root.to_path_buf(), src]
} else {
let src = project_root.join("src");

// Default to `src` and the project root if `src` exists and the root hasn't been specified.
if system.is_directory(&src) {
vec![project_root.to_path_buf(), src]
} else {
vec![project_root.to_path_buf()]
}
};
vec![project_root.to_path_buf()]
}
};

let (extra_paths, python, typeshed) = self
.environment
Expand All @@ -71,11 +73,17 @@ impl Options {
.unwrap_or_default();

SearchPathSettings {
extra_paths: extra_paths.unwrap_or_default(),
extra_paths: extra_paths
.unwrap_or_default()
.into_iter()
.map(|path| path.absolute(project_root, system))
.collect(),
src_roots,
typeshed,
typeshed: typeshed.map(|path| path.absolute(project_root, system)),
site_packages: python
.map(|venv_path| SitePackages::Derived { venv_path })
.map(|venv_path| SitePackages::Derived {
venv_path: venv_path.absolute(project_root, system),
})
.unwrap_or(SitePackages::Known(vec![])),
}
}
Expand All @@ -91,23 +99,23 @@ pub struct EnvironmentOptions {
/// List of user-provided paths that should take first priority in the module resolution.
/// Examples in other type checkers are mypy's MYPYPATH environment variable,
/// or pyright's stubPath configuration setting.
pub extra_paths: Option<Vec<SystemPathBuf>>,
pub extra_paths: Option<Vec<RelativePathBuf>>,

/// Optional path to a "typeshed" directory on disk for us to use for standard-library types.
/// If this is not provided, we will fallback to our vendored typeshed stubs for the stdlib,
/// bundled as a zip file in the binary
pub typeshed: Option<SystemPathBuf>,
pub typeshed: Option<RelativePathBuf>,

// TODO: Rename to python, see https://github.com/astral-sh/ruff/issues/15530
/// The path to the user's `site-packages` directory, where third-party packages from ``PyPI`` are installed.
pub venv_path: Option<SystemPathBuf>,
pub venv_path: Option<RelativePathBuf>,
}

#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct SrcOptions {
/// The root of the project, used for finding first-party modules.
pub root: Option<SystemPathBuf>,
pub root: Option<RelativePathBuf>,
}

#[derive(Error, Debug)]
Expand Down
Loading

0 comments on commit 545afe8

Please sign in to comment.