Skip to content

Commit

Permalink
[red-knot] Anchor relative paths in configurations (#15634)
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaReiser authored Jan 23, 2025
1 parent ce81103 commit 39e2df7
Show file tree
Hide file tree
Showing 7 changed files with 383 additions and 46 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
162 changes: 158 additions & 4 deletions crates/red_knot/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use anyhow::Context;
use insta_cmd::{assert_cmd_snapshot, get_cargo_bin};
use std::path::Path;
use std::process::Command;
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 All @@ -29,7 +30,7 @@ print(sys.last_exc)
)
.context("Failed to write test.py")?;

insta::with_settings!({filters => vec![(&*tempdir_filter(&tempdir), "<temp_dir>/")]}, {
insta::with_settings!({filters => vec![(&*tempdir_filter(tempdir.path()), "<temp_dir>/")]}, {
assert_cmd_snapshot!(knot().arg("--project").arg(tempdir.path()), @r"
success: false
exit_code: 1
Expand All @@ -51,10 +52,163 @@ 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().canonicalize()?;

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`")?;

let project_filter = tempdir_filter(&project_dir);
let filters = vec![
(&*project_filter, "<temp_dir>/"),
(r#"\\(\w\w|\s|\.|")"#, "/$1"),
];

// Make sure that the CLI fails when the `libs` directory is not in the search path.
insta::with_settings!({filters => filters}, {
assert_cmd_snapshot!(knot().current_dir(&child), @r#"
success: false
exit_code: 1
----- stdout -----
error[lint:unresolved-import] <temp_dir>/child/test.py:2:1 Cannot resolve import `utils`
----- stderr -----
"#);
});

insta::with_settings!({filters => vec![(&*tempdir_filter(&project_dir), "<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.path()), "<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"))
}

fn tempdir_filter(tempdir: &TempDir) -> String {
format!(r"{}\\?/?", regex::escape(tempdir.path().to_str().unwrap()))
fn tempdir_filter(path: &Path) -> String {
format!(r"{}\\?/?", regex::escape(path.to_str().unwrap()))
}
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
Loading

0 comments on commit 39e2df7

Please sign in to comment.