[ty] Add --config-file CLI arg (#18083)

This commit is contained in:
justin 2025-05-26 23:00:38 -07:00 committed by GitHub
parent 6453ac9ea1
commit 8d5655a7ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 300 additions and 63 deletions

View file

@ -78,7 +78,7 @@ fn setup_tomllib_case() -> Case {
let src_root = SystemPath::new("/src"); let src_root = SystemPath::new("/src");
let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap();
metadata.apply_cli_options(Options { metadata.apply_options(Options {
environment: Some(EnvironmentOptions { environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(PythonVersion::PY312)), python_version: Some(RangedValue::cli(PythonVersion::PY312)),
..EnvironmentOptions::default() ..EnvironmentOptions::default()
@ -224,7 +224,7 @@ fn setup_micro_case(code: &str) -> Case {
let src_root = SystemPath::new("/src"); let src_root = SystemPath::new("/src");
let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap();
metadata.apply_cli_options(Options { metadata.apply_options(Options {
environment: Some(EnvironmentOptions { environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(PythonVersion::PY312)), python_version: Some(RangedValue::cli(PythonVersion::PY312)),
..EnvironmentOptions::default() ..EnvironmentOptions::default()

View file

@ -22,7 +22,7 @@ ty_server = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
argfile = { workspace = true } argfile = { workspace = true }
clap = { workspace = true, features = ["wrap_help", "string"] } clap = { workspace = true, features = ["wrap_help", "string", "env"] }
clap_complete_command = { workspace = true } clap_complete_command = { workspace = true }
colored = { workspace = true } colored = { workspace = true }
countme = { workspace = true, features = ["enable"] } countme = { workspace = true, features = ["enable"] }

4
crates/ty/docs/cli.md generated
View file

@ -47,7 +47,9 @@ ty check [OPTIONS] [PATH]...
overriding a specific configuration option.</p> overriding a specific configuration option.</p>
<p>Overrides of individual settings using this option always take precedence <p>Overrides of individual settings using this option always take precedence
over all configuration files.</p> over all configuration files.</p>
</dd><dt id="ty-check--error"><a href="#ty-check--error"><code>--error</code></a> <i>rule</i></dt><dd><p>Treat the given rule as having severity 'error'. Can be specified multiple times.</p> </dd><dt id="ty-check--config-file"><a href="#ty-check--config-file"><code>--config-file</code></a> <i>path</i></dt><dd><p>The path to a <code>ty.toml</code> file to use for configuration.</p>
<p>While ty configuration can be included in a <code>pyproject.toml</code> file, it is not allowed in this context.</p>
<p>May also be set with the <code>TY_CONFIG_FILE</code> environment variable.</p></dd><dt id="ty-check--error"><a href="#ty-check--error"><code>--error</code></a> <i>rule</i></dt><dd><p>Treat the given rule as having severity 'error'. Can be specified multiple times.</p>
</dd><dt id="ty-check--error-on-warning"><a href="#ty-check--error-on-warning"><code>--error-on-warning</code></a></dt><dd><p>Use exit code 1 if there are any warning-level diagnostics</p> </dd><dt id="ty-check--error-on-warning"><a href="#ty-check--error-on-warning"><code>--error-on-warning</code></a></dt><dd><p>Use exit code 1 if there are any warning-level diagnostics</p>
</dd><dt id="ty-check--exit-zero"><a href="#ty-check--exit-zero"><code>--exit-zero</code></a></dt><dd><p>Always use exit code 0, even when there are error-level diagnostics</p> </dd><dt id="ty-check--exit-zero"><a href="#ty-check--exit-zero"><code>--exit-zero</code></a></dt><dd><p>Always use exit code 0, even when there are error-level diagnostics</p>
</dd><dt id="ty-check--extra-search-path"><a href="#ty-check--extra-search-path"><code>--extra-search-path</code></a> <i>path</i></dt><dd><p>Additional path to use as a module-resolution source (can be passed multiple times)</p> </dd><dt id="ty-check--extra-search-path"><a href="#ty-check--extra-search-path"><code>--extra-search-path</code></a> <i>path</i></dt><dd><p>Additional path to use as a module-resolution source (can be passed multiple times)</p>

View file

@ -107,6 +107,12 @@ pub(crate) struct CheckCommand {
#[clap(flatten)] #[clap(flatten)]
pub(crate) config: ConfigsArg, pub(crate) config: ConfigsArg,
/// The path to a `ty.toml` file to use for configuration.
///
/// While ty configuration can be included in a `pyproject.toml` file, it is not allowed in this context.
#[arg(long, env = "TY_CONFIG_FILE", value_name = "PATH")]
pub(crate) config_file: Option<SystemPathBuf>,
/// The format to use for printing diagnostic messages. /// The format to use for printing diagnostic messages.
#[arg(long)] #[arg(long)]
pub(crate) output_format: Option<OutputFormat>, pub(crate) output_format: Option<OutputFormat>,

View file

@ -23,7 +23,7 @@ use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity};
use ruff_db::max_parallelism; use ruff_db::max_parallelism;
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
use salsa::plumbing::ZalsaDatabase; use salsa::plumbing::ZalsaDatabase;
use ty_project::metadata::options::Options; use ty_project::metadata::options::ProjectOptionsOverrides;
use ty_project::watch::ProjectWatcher; use ty_project::watch::ProjectWatcher;
use ty_project::{Db, DummyReporter, Reporter, watch}; use ty_project::{Db, DummyReporter, Reporter, watch};
use ty_project::{ProjectDatabase, ProjectMetadata}; use ty_project::{ProjectDatabase, ProjectMetadata};
@ -102,13 +102,21 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
.map(|path| SystemPath::absolute(path, &cwd)) .map(|path| SystemPath::absolute(path, &cwd))
.collect(); .collect();
let system = OsSystem::new(cwd); let system = OsSystem::new(&cwd);
let watch = args.watch; let watch = args.watch;
let exit_zero = args.exit_zero; let exit_zero = args.exit_zero;
let config_file = args
.config_file
.as_ref()
.map(|path| SystemPath::absolute(path, &cwd));
let cli_options = args.into_options(); let mut project_metadata = match &config_file {
let mut project_metadata = ProjectMetadata::discover(&project_path, &system)?; Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
project_metadata.apply_cli_options(cli_options.clone()); None => ProjectMetadata::discover(&project_path, &system)?,
};
let options = args.into_options();
project_metadata.apply_options(options.clone());
project_metadata.apply_configuration_files(&system)?; project_metadata.apply_configuration_files(&system)?;
let mut db = ProjectDatabase::new(project_metadata, system)?; let mut db = ProjectDatabase::new(project_metadata, system)?;
@ -117,7 +125,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
db.project().set_included_paths(&mut db, check_paths); db.project().set_included_paths(&mut db, check_paths);
} }
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options); let project_options_overrides = ProjectOptionsOverrides::new(config_file, options);
let (main_loop, main_loop_cancellation_token) = MainLoop::new(project_options_overrides);
// Listen to Ctrl+C and abort the watch mode. // Listen to Ctrl+C and abort the watch mode.
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token)); let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
@ -178,11 +187,13 @@ struct MainLoop {
/// The file system watcher, if running in watch mode. /// The file system watcher, if running in watch mode.
watcher: Option<ProjectWatcher>, watcher: Option<ProjectWatcher>,
cli_options: Options, project_options_overrides: ProjectOptionsOverrides,
} }
impl MainLoop { impl MainLoop {
fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) { fn new(
project_options_overrides: ProjectOptionsOverrides,
) -> (Self, MainLoopCancellationToken) {
let (sender, receiver) = crossbeam_channel::bounded(10); let (sender, receiver) = crossbeam_channel::bounded(10);
( (
@ -190,7 +201,7 @@ impl MainLoop {
sender: sender.clone(), sender: sender.clone(),
receiver, receiver,
watcher: None, watcher: None,
cli_options, project_options_overrides,
}, },
MainLoopCancellationToken { sender }, MainLoopCancellationToken { sender },
) )
@ -340,7 +351,7 @@ impl MainLoop {
MainLoopMessage::ApplyChanges(changes) => { MainLoopMessage::ApplyChanges(changes) => {
revision += 1; revision += 1;
// Automatically cancels any pending queries and waits for them to complete. // Automatically cancels any pending queries and waits for them to complete.
db.apply_changes(changes, Some(&self.cli_options)); db.apply_changes(changes, Some(&self.project_options_overrides));
if let Some(watcher) = self.watcher.as_mut() { if let Some(watcher) = self.watcher.as_mut() {
watcher.update(db); watcher.update(db);
} }

View file

@ -1700,6 +1700,63 @@ fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> {
----- stderr ----- ----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"); ");
Ok(())
}
#[test]
fn config_file_override() -> anyhow::Result<()> {
// Set `error-on-warning` to true in the configuration file
// Explicitly set `--warn unresolved-reference` to ensure the rule warns instead of errors
let case = TestCase::with_files(vec![
("test.py", r"print(x) # [unresolved-reference]"),
(
"ty-override.toml",
r#"
[terminal]
error-on-warning = true
"#,
),
])?;
// Ensure flag works via CLI arg
assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config-file").arg("ty-override.toml"), @r"
success: false
exit_code: 1
----- stdout -----
warning[unresolved-reference]: Name `x` used when not defined
--> test.py:1:7
|
1 | print(x) # [unresolved-reference]
| ^
|
info: rule `unresolved-reference` was selected on the command line
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Ensure the flag works via an environment variable
assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").env("TY_CONFIG_FILE", "ty-override.toml"), @r"
success: false
exit_code: 1
----- stdout -----
warning[unresolved-reference]: Name `x` used when not defined
--> test.py:1:7
|
1 | print(x) # [unresolved-reference]
| ^
|
info: rule `unresolved-reference` was selected on the command line
Found 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(()) Ok(())
} }

View file

@ -10,7 +10,7 @@ use ruff_db::system::{
}; };
use ruff_db::{Db as _, Upcast}; use ruff_db::{Db as _, Upcast};
use ruff_python_ast::PythonVersion; use ruff_python_ast::PythonVersion;
use ty_project::metadata::options::{EnvironmentOptions, Options}; use ty_project::metadata::options::{EnvironmentOptions, Options, ProjectOptionsOverrides};
use ty_project::metadata::pyproject::{PyProject, Tool}; use ty_project::metadata::pyproject::{PyProject, Tool};
use ty_project::metadata::value::{RangedValue, RelativePathBuf}; use ty_project::metadata::value::{RangedValue, RelativePathBuf};
use ty_project::watch::{ChangeEvent, ProjectWatcher, directory_watcher}; use ty_project::watch::{ChangeEvent, ProjectWatcher, directory_watcher};
@ -164,8 +164,12 @@ impl TestCase {
Ok(all_events) Ok(all_events)
} }
fn apply_changes(&mut self, changes: Vec<ChangeEvent>) { fn apply_changes(
self.db.apply_changes(changes, None); &mut self,
changes: Vec<ChangeEvent>,
project_options_overrides: Option<&ProjectOptionsOverrides>,
) {
self.db.apply_changes(changes, project_options_overrides);
} }
fn update_options(&mut self, options: Options) -> anyhow::Result<()> { fn update_options(&mut self, options: Options) -> anyhow::Result<()> {
@ -180,7 +184,7 @@ impl TestCase {
.context("Failed to write configuration")?; .context("Failed to write configuration")?;
let changes = self.take_watch_changes(event_for_file("pyproject.toml")); let changes = self.take_watch_changes(event_for_file("pyproject.toml"));
self.apply_changes(changes); self.apply_changes(changes, None);
if let Some(watcher) = &mut self.watcher { if let Some(watcher) = &mut self.watcher {
watcher.update(&self.db); watcher.update(&self.db);
@ -476,7 +480,7 @@ fn new_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
let foo = case.system_file(&foo_path).expect("foo.py to exist."); let foo = case.system_file(&foo_path).expect("foo.py to exist.");
@ -499,7 +503,7 @@ fn new_ignored_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(case.system_file(&foo_path).is_ok()); assert!(case.system_file(&foo_path).is_ok());
case.assert_indexed_project_files([bar_file]); case.assert_indexed_project_files([bar_file]);
@ -535,7 +539,7 @@ fn new_non_project_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("black.py")); let changes = case.stop_watch(event_for_file("black.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(case.system_file(&black_path).is_ok()); assert!(case.system_file(&black_path).is_ok());
@ -576,7 +580,7 @@ fn new_files_with_explicit_included_paths() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("test2.py")); let changes = case.stop_watch(event_for_file("test2.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist"); let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist");
@ -621,7 +625,7 @@ fn new_file_in_included_out_of_project_directory() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("script2.py")); let changes = case.stop_watch(event_for_file("script2.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
let src_a_file = case.system_file(&src_a).unwrap(); let src_a_file = case.system_file(&src_a).unwrap();
let outside_b_file = case.system_file(&outside_b_path).unwrap(); let outside_b_file = case.system_file(&outside_b_path).unwrap();
@ -648,7 +652,7 @@ fn changed_file() -> anyhow::Result<()> {
assert!(!changes.is_empty()); assert!(!changes.is_empty());
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
case.assert_indexed_project_files([foo]); case.assert_indexed_project_files([foo]);
@ -671,7 +675,7 @@ fn deleted_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(!foo.exists(case.db())); assert!(!foo.exists(case.db()));
case.assert_indexed_project_files([]); case.assert_indexed_project_files([]);
@ -703,7 +707,7 @@ fn move_file_to_trash() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(!foo.exists(case.db())); assert!(!foo.exists(case.db()));
case.assert_indexed_project_files([]); case.assert_indexed_project_files([]);
@ -730,7 +734,7 @@ fn move_file_to_project() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
let foo_in_project = case.system_file(&foo_in_project)?; let foo_in_project = case.system_file(&foo_in_project)?;
@ -755,7 +759,7 @@ fn rename_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("bar.py")); let changes = case.stop_watch(event_for_file("bar.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(!foo.exists(case.db())); assert!(!foo.exists(case.db()));
@ -796,7 +800,7 @@ fn directory_moved_to_project() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("sub")); let changes = case.stop_watch(event_for_file("sub"));
case.apply_changes(changes); case.apply_changes(changes, None);
let init_file = case let init_file = case
.system_file(sub_new_path.join("__init__.py")) .system_file(sub_new_path.join("__init__.py"))
@ -853,7 +857,7 @@ fn directory_moved_to_trash() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("sub")); let changes = case.stop_watch(event_for_file("sub"));
case.apply_changes(changes); case.apply_changes(changes, None);
// `import sub.a` should no longer resolve // `import sub.a` should no longer resolve
assert!( assert!(
@ -916,7 +920,7 @@ fn directory_renamed() -> anyhow::Result<()> {
// Linux and windows only emit an event for the newly created root directory, but not for every new component. // Linux and windows only emit an event for the newly created root directory, but not for every new component.
let changes = case.stop_watch(event_for_file("sub")); let changes = case.stop_watch(event_for_file("sub"));
case.apply_changes(changes); case.apply_changes(changes, None);
// `import sub.a` should no longer resolve // `import sub.a` should no longer resolve
assert!( assert!(
@ -989,7 +993,7 @@ fn directory_deleted() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("sub")); let changes = case.stop_watch(event_for_file("sub"));
case.apply_changes(changes); case.apply_changes(changes, None);
// `import sub.a` should no longer resolve // `import sub.a` should no longer resolve
assert!( assert!(
@ -1035,7 +1039,7 @@ fn search_path() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("a.py")); let changes = case.stop_watch(event_for_file("a.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some()); assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some());
case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]); case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]);
@ -1066,7 +1070,7 @@ fn add_search_path() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("a.py")); let changes = case.stop_watch(event_for_file("a.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some()); assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some());
@ -1213,7 +1217,7 @@ fn changed_versions_file() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("VERSIONS")); let changes = case.stop_watch(event_for_file("VERSIONS"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert!(resolve_module(case.db(), &ModuleName::new("os").unwrap()).is_some()); assert!(resolve_module(case.db(), &ModuleName::new("os").unwrap()).is_some());
@ -1267,7 +1271,7 @@ fn hard_links_in_project() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
@ -1338,7 +1342,7 @@ fn hard_links_to_target_outside_project() -> anyhow::Result<()> {
let changes = case.stop_watch(ChangeEvent::is_changed); let changes = case.stop_watch(ChangeEvent::is_changed);
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')"); assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')");
@ -1377,7 +1381,7 @@ mod unix {
let changes = case.stop_watch(event_for_file("foo.py")); let changes = case.stop_watch(event_for_file("foo.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!( assert_eq!(
foo.permissions(case.db()), foo.permissions(case.db()),
@ -1460,7 +1464,7 @@ mod unix {
let changes = case.take_watch_changes(event_for_file("baz.py")); let changes = case.take_watch_changes(event_for_file("baz.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!( assert_eq!(
source_text(case.db(), baz_file).as_str(), source_text(case.db(), baz_file).as_str(),
@ -1473,7 +1477,7 @@ mod unix {
let changes = case.stop_watch(event_for_file("baz.py")); let changes = case.stop_watch(event_for_file("baz.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!( assert_eq!(
source_text(case.db(), baz_file).as_str(), source_text(case.db(), baz_file).as_str(),
@ -1544,7 +1548,7 @@ mod unix {
let changes = case.stop_watch(event_for_file("baz.py")); let changes = case.stop_watch(event_for_file("baz.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
// The file watcher is guaranteed to emit one event for the changed file, but it isn't specified // The file watcher is guaranteed to emit one event for the changed file, but it isn't specified
// if the event is emitted for the "original" or linked path because both paths are watched. // if the event is emitted for the "original" or linked path because both paths are watched.
@ -1658,7 +1662,7 @@ mod unix {
let changes = case.stop_watch(event_for_file("baz.py")); let changes = case.stop_watch(event_for_file("baz.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
assert_eq!( assert_eq!(
source_text(case.db(), baz_original_file).as_str(), source_text(case.db(), baz_original_file).as_str(),
@ -1715,7 +1719,7 @@ fn nested_projects_delete_root() -> anyhow::Result<()> {
let changes = case.stop_watch(ChangeEvent::is_deleted); let changes = case.stop_watch(ChangeEvent::is_deleted);
case.apply_changes(changes); case.apply_changes(changes, None);
// It should now pick up the outer project. // It should now pick up the outer project.
assert_eq!(case.db().project().root(case.db()), case.root_path()); assert_eq!(case.db().project().root(case.db()), case.root_path());
@ -1781,7 +1785,73 @@ fn changes_to_user_configuration() -> anyhow::Result<()> {
let changes = case.stop_watch(event_for_file("ty.toml")); let changes = case.stop_watch(event_for_file("ty.toml"));
case.apply_changes(changes); case.apply_changes(changes, None);
let diagnostics = case.db().check_file(foo);
assert!(
diagnostics.len() == 1,
"Expected exactly one diagnostic but got: {diagnostics:#?}"
);
Ok(())
}
#[test]
fn changes_to_config_file_override() -> anyhow::Result<()> {
let mut case = setup(|context: &mut SetupContext| {
std::fs::write(
context.join_project_path("pyproject.toml").as_std_path(),
r#"
[project]
name = "test"
"#,
)?;
std::fs::write(
context.join_project_path("foo.py").as_std_path(),
"a = 10 / 0",
)?;
std::fs::write(
context.join_project_path("ty-override.toml").as_std_path(),
r#"
[rules]
division-by-zero = "ignore"
"#,
)?;
Ok(())
})?;
let foo = case
.system_file(case.project_path("foo.py"))
.expect("foo.py to exist");
let diagnostics = case.db().check_file(foo);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics but got: {diagnostics:#?}"
);
// Enable division-by-zero in the explicitly specified configuration with warning severity
update_file(
case.project_path("ty-override.toml"),
r#"
[rules]
division-by-zero = "warn"
"#,
)?;
let changes = case.stop_watch(event_for_file("ty-override.toml"));
case.apply_changes(
changes,
Some(&ProjectOptionsOverrides::new(
Some(case.project_path("ty-override.toml")),
Options::default(),
)),
);
let diagnostics = case.db().check_file(foo); let diagnostics = case.db().check_file(foo);
@ -1855,7 +1925,7 @@ fn rename_files_casing_only() -> anyhow::Result<()> {
} }
let changes = case.stop_watch(event_for_file("Lib.py")); let changes = case.stop_watch(event_for_file("Lib.py"));
case.apply_changes(changes); case.apply_changes(changes, None);
// Resolving `lib` should now fail but `Lib` should now succeed // Resolving `lib` should now fail but `Lib` should now succeed
assert_eq!( assert_eq!(

View file

@ -1,5 +1,5 @@
use crate::db::{Db, ProjectDatabase}; use crate::db::{Db, ProjectDatabase};
use crate::metadata::options::Options; use crate::metadata::options::ProjectOptionsOverrides;
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind}; use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
use crate::{Project, ProjectMetadata}; use crate::{Project, ProjectMetadata};
use std::collections::BTreeSet; use std::collections::BTreeSet;
@ -12,10 +12,18 @@ use rustc_hash::FxHashSet;
use ty_python_semantic::Program; use ty_python_semantic::Program;
impl ProjectDatabase { impl ProjectDatabase {
#[tracing::instrument(level = "debug", skip(self, changes, cli_options))] #[tracing::instrument(level = "debug", skip(self, changes, project_options_overrides))]
pub fn apply_changes(&mut self, changes: Vec<ChangeEvent>, cli_options: Option<&Options>) { pub fn apply_changes(
&mut self,
changes: Vec<ChangeEvent>,
project_options_overrides: Option<&ProjectOptionsOverrides>,
) {
let mut project = self.project(); let mut project = self.project();
let project_root = project.root(self).to_path_buf(); let project_root = project.root(self).to_path_buf();
let config_file_override =
project_options_overrides.and_then(|options| options.config_file_override.clone());
let options =
project_options_overrides.map(|project_options| project_options.options.clone());
let program = Program::get(self); let program = Program::get(self);
let custom_stdlib_versions_path = program let custom_stdlib_versions_path = program
.custom_stdlib_search_path(self) .custom_stdlib_search_path(self)
@ -42,6 +50,14 @@ impl ProjectDatabase {
tracing::trace!("Handle change: {:?}", change); tracing::trace!("Handle change: {:?}", change);
if let Some(path) = change.system_path() { if let Some(path) = change.system_path() {
if let Some(config_file) = &config_file_override {
if config_file.as_path() == path {
project_changed = true;
continue;
}
}
if matches!( if matches!(
path.file_name(), path.file_name(),
Some(".gitignore" | ".ignore" | "ty.toml" | "pyproject.toml") Some(".gitignore" | ".ignore" | "ty.toml" | "pyproject.toml")
@ -170,10 +186,14 @@ impl ProjectDatabase {
} }
if project_changed { if project_changed {
match ProjectMetadata::discover(&project_root, self.system()) { let new_project_metadata = match config_file_override {
Some(config_file) => ProjectMetadata::from_config_file(config_file, self.system()),
None => ProjectMetadata::discover(&project_root, self.system()),
};
match new_project_metadata {
Ok(mut metadata) => { Ok(mut metadata) => {
if let Some(cli_options) = cli_options { if let Some(cli_options) = options {
metadata.apply_cli_options(cli_options.clone()); metadata.apply_options(cli_options);
} }
if let Err(error) = metadata.apply_configuration_files(self.system()) { if let Err(error) = metadata.apply_configuration_files(self.system()) {

View file

@ -5,7 +5,7 @@ use crate::walk::{ProjectFilesFilter, ProjectFilesWalker};
pub use db::{Db, ProjectDatabase}; pub use db::{Db, ProjectDatabase};
use files::{Index, Indexed, IndexedFiles}; use files::{Index, Indexed, IndexedFiles};
use metadata::settings::Settings; use metadata::settings::Settings;
pub use metadata::{ProjectDiscoveryError, ProjectMetadata}; pub use metadata::{ProjectMetadata, ProjectMetadataError};
use ruff_db::diagnostic::{ use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, create_parse_diagnostic, Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, create_parse_diagnostic,
create_unsupported_syntax_diagnostic, create_unsupported_syntax_diagnostic,

View file

@ -48,6 +48,29 @@ impl ProjectMetadata {
} }
} }
pub fn from_config_file(
path: SystemPathBuf,
system: &dyn System,
) -> Result<Self, ProjectMetadataError> {
tracing::debug!("Using overridden configuration file at '{path}'");
let config_file = ConfigurationFile::from_path(path.clone(), system).map_err(|error| {
ProjectMetadataError::ConfigurationFileError {
source: Box::new(error),
path: path.clone(),
}
})?;
let options = config_file.into_options();
Ok(Self {
name: Name::new(system.current_directory().file_name().unwrap_or("root")),
root: system.current_directory().to_path_buf(),
options,
extra_configuration_paths: vec![path],
})
}
/// Loads a project from a `pyproject.toml` file. /// Loads a project from a `pyproject.toml` file.
pub(crate) fn from_pyproject( pub(crate) fn from_pyproject(
pyproject: PyProject, pyproject: PyProject,
@ -106,11 +129,11 @@ impl ProjectMetadata {
pub fn discover( pub fn discover(
path: &SystemPath, path: &SystemPath,
system: &dyn System, system: &dyn System,
) -> Result<ProjectMetadata, ProjectDiscoveryError> { ) -> Result<ProjectMetadata, ProjectMetadataError> {
tracing::debug!("Searching for a project in '{path}'"); tracing::debug!("Searching for a project in '{path}'");
if !system.is_directory(path) { if !system.is_directory(path) {
return Err(ProjectDiscoveryError::NotADirectory(path.to_path_buf())); return Err(ProjectMetadataError::NotADirectory(path.to_path_buf()));
} }
let mut closest_project: Option<ProjectMetadata> = None; let mut closest_project: Option<ProjectMetadata> = None;
@ -125,7 +148,7 @@ impl ProjectMetadata {
) { ) {
Ok(pyproject) => Some(pyproject), Ok(pyproject) => Some(pyproject),
Err(error) => { Err(error) => {
return Err(ProjectDiscoveryError::InvalidPyProject { return Err(ProjectMetadataError::InvalidPyProject {
path: pyproject_path, path: pyproject_path,
source: Box::new(error), source: Box::new(error),
}); });
@ -144,7 +167,7 @@ impl ProjectMetadata {
) { ) {
Ok(options) => options, Ok(options) => options,
Err(error) => { Err(error) => {
return Err(ProjectDiscoveryError::InvalidTyToml { return Err(ProjectMetadataError::InvalidTyToml {
path: ty_toml_path, path: ty_toml_path,
source: Box::new(error), source: Box::new(error),
}); });
@ -171,7 +194,7 @@ impl ProjectMetadata {
.and_then(|pyproject| pyproject.project.as_ref()), .and_then(|pyproject| pyproject.project.as_ref()),
) )
.map_err(|err| { .map_err(|err| {
ProjectDiscoveryError::InvalidRequiresPythonConstraint { ProjectMetadataError::InvalidRequiresPythonConstraint {
source: err, source: err,
path: pyproject_path, path: pyproject_path,
} }
@ -185,7 +208,7 @@ impl ProjectMetadata {
let metadata = let metadata =
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf()) ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf())
.map_err( .map_err(
|err| ProjectDiscoveryError::InvalidRequiresPythonConstraint { |err| ProjectMetadataError::InvalidRequiresPythonConstraint {
source: err, source: err,
path: pyproject_path, path: pyproject_path,
}, },
@ -249,7 +272,7 @@ impl ProjectMetadata {
} }
/// Combine the project options with the CLI options where the CLI options take precedence. /// Combine the project options with the CLI options where the CLI options take precedence.
pub fn apply_cli_options(&mut self, options: Options) { pub fn apply_options(&mut self, options: Options) {
self.options = options.combine(std::mem::take(&mut self.options)); self.options = options.combine(std::mem::take(&mut self.options));
} }
@ -282,7 +305,7 @@ impl ProjectMetadata {
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ProjectDiscoveryError { pub enum ProjectMetadataError {
#[error("project path '{0}' is not a directory")] #[error("project path '{0}' is not a directory")]
NotADirectory(SystemPathBuf), NotADirectory(SystemPathBuf),
@ -303,6 +326,12 @@ pub enum ProjectDiscoveryError {
source: ResolveRequiresPythonError, source: ResolveRequiresPythonError,
path: SystemPathBuf, path: SystemPathBuf,
}, },
#[error("Error loading configuration file at {path}: {source}")]
ConfigurationFileError {
source: Box<ConfigurationFileError>,
path: SystemPathBuf,
},
} }
#[cfg(test)] #[cfg(test)]
@ -314,7 +343,7 @@ mod tests {
use ruff_db::system::{SystemPathBuf, TestSystem}; use ruff_db::system::{SystemPathBuf, TestSystem};
use ruff_python_ast::PythonVersion; use ruff_python_ast::PythonVersion;
use crate::{ProjectDiscoveryError, ProjectMetadata}; use crate::{ProjectMetadata, ProjectMetadataError};
#[test] #[test]
fn project_without_pyproject() -> anyhow::Result<()> { fn project_without_pyproject() -> anyhow::Result<()> {
@ -1076,7 +1105,7 @@ expected `.`, `]`
} }
#[track_caller] #[track_caller]
fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) { fn assert_error_eq(error: &ProjectMetadataError, message: &str) {
assert_eq!(error.to_string().replace('\\', "/"), message); assert_eq!(error.to_string().replace('\\', "/"), message);
} }

View file

@ -14,6 +14,25 @@ pub(crate) struct ConfigurationFile {
} }
impl ConfigurationFile { impl ConfigurationFile {
pub(crate) fn from_path(
path: SystemPathBuf,
system: &dyn System,
) -> Result<Self, ConfigurationFileError> {
let ty_toml_str = system.read_to_string(&path).map_err(|source| {
ConfigurationFileError::FileReadError {
source,
path: path.clone(),
}
})?;
match Options::from_toml_str(&ty_toml_str, ValueSource::File(Arc::new(path.clone()))) {
Ok(options) => Ok(Self { path, options }),
Err(error) => Err(ConfigurationFileError::InvalidTyToml {
source: Box::new(error),
path,
}),
}
}
/// Loads the user-level configuration file if it exists. /// Loads the user-level configuration file if it exists.
/// ///
/// Returns `None` if the file does not exist or if the concept of user-level configurations /// Returns `None` if the file does not exist or if the concept of user-level configurations
@ -66,4 +85,10 @@ pub enum ConfigurationFileError {
source: Box<TyTomlError>, source: Box<TyTomlError>,
path: SystemPathBuf, path: SystemPathBuf,
}, },
#[error("Failed to read `{path}`: {source}")]
FileReadError {
#[source]
source: std::io::Error,
path: SystemPathBuf,
},
} }

View file

@ -2,7 +2,7 @@ use crate::Db;
use crate::metadata::value::{RangedValue, RelativePathBuf, ValueSource, ValueSourceGuard}; use crate::metadata::value::{RangedValue, RelativePathBuf, ValueSource, ValueSourceGuard};
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, Severity, Span}; use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, Severity, Span};
use ruff_db::files::system_path_to_file; use ruff_db::files::system_path_to_file;
use ruff_db::system::{System, SystemPath}; use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_macros::{Combine, OptionsMetadata}; use ruff_macros::{Combine, OptionsMetadata};
use ruff_python_ast::PythonVersion; use ruff_python_ast::PythonVersion;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
@ -575,3 +575,20 @@ impl OptionDiagnostic {
} }
} }
} }
/// This is a wrapper for options that actually get loaded from configuration files
/// and the CLI, which also includes a `config_file_override` option that overrides
/// default configuration discovery with an explicitly-provided path to a configuration file
pub struct ProjectOptionsOverrides {
pub config_file_override: Option<SystemPathBuf>,
pub options: Options,
}
impl ProjectOptionsOverrides {
pub fn new(config_file_override: Option<SystemPathBuf>, options: Options) -> Self {
Self {
config_file_override,
options,
}
}
}