diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs
index c36e120274..fe974de25b 100644
--- a/crates/ruff_benchmark/benches/ty.rs
+++ b/crates/ruff_benchmark/benches/ty.rs
@@ -78,7 +78,7 @@ fn setup_tomllib_case() -> Case {
let src_root = SystemPath::new("/src");
let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap();
- metadata.apply_cli_options(Options {
+ metadata.apply_options(Options {
environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
..EnvironmentOptions::default()
@@ -224,7 +224,7 @@ fn setup_micro_case(code: &str) -> Case {
let src_root = SystemPath::new("/src");
let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap();
- metadata.apply_cli_options(Options {
+ metadata.apply_options(Options {
environment: Some(EnvironmentOptions {
python_version: Some(RangedValue::cli(PythonVersion::PY312)),
..EnvironmentOptions::default()
diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml
index cd287501fb..cd8f7b32ea 100644
--- a/crates/ty/Cargo.toml
+++ b/crates/ty/Cargo.toml
@@ -22,7 +22,7 @@ ty_server = { workspace = true }
anyhow = { 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 }
colored = { workspace = true }
countme = { workspace = true, features = ["enable"] }
diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md
index 3df1548a31..c98543117e 100644
--- a/crates/ty/docs/cli.md
+++ b/crates/ty/docs/cli.md
@@ -47,7 +47,9 @@ ty check [OPTIONS] [PATH]...
overriding a specific configuration option.
Overrides of individual settings using this option always take precedence
over all configuration files.
---error
ruleTreat the given rule as having severity 'error'. Can be specified multiple times.
+--config-file
pathThe 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.
+May also be set with the TY_CONFIG_FILE
environment variable.
--error
ruleTreat the given rule as having severity 'error'. Can be specified multiple times.
--error-on-warning
Use exit code 1 if there are any warning-level diagnostics
--exit-zero
Always use exit code 0, even when there are error-level diagnostics
Additional path to use as a module-resolution source (can be passed multiple times)
diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs
index 9a48b310ec..d7f49f79f8 100644
--- a/crates/ty/src/args.rs
+++ b/crates/ty/src/args.rs
@@ -107,6 +107,12 @@ pub(crate) struct CheckCommand {
#[clap(flatten)]
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,
+
/// The format to use for printing diagnostic messages.
#[arg(long)]
pub(crate) output_format: Option,
diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs
index ec66b60ca5..91efddde60 100644
--- a/crates/ty/src/lib.rs
+++ b/crates/ty/src/lib.rs
@@ -23,7 +23,7 @@ use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity};
use ruff_db::max_parallelism;
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
use salsa::plumbing::ZalsaDatabase;
-use ty_project::metadata::options::Options;
+use ty_project::metadata::options::ProjectOptionsOverrides;
use ty_project::watch::ProjectWatcher;
use ty_project::{Db, DummyReporter, Reporter, watch};
use ty_project::{ProjectDatabase, ProjectMetadata};
@@ -102,13 +102,21 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
.map(|path| SystemPath::absolute(path, &cwd))
.collect();
- let system = OsSystem::new(cwd);
+ let system = OsSystem::new(&cwd);
let watch = args.watch;
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 = ProjectMetadata::discover(&project_path, &system)?;
- project_metadata.apply_cli_options(cli_options.clone());
+ let mut project_metadata = match &config_file {
+ Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
+ None => ProjectMetadata::discover(&project_path, &system)?,
+ };
+
+ let options = args.into_options();
+ project_metadata.apply_options(options.clone());
project_metadata.apply_configuration_files(&system)?;
let mut db = ProjectDatabase::new(project_metadata, system)?;
@@ -117,7 +125,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result {
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.
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.
watcher: Option,
- cli_options: Options,
+ project_options_overrides: ProjectOptionsOverrides,
}
impl MainLoop {
- fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) {
+ fn new(
+ project_options_overrides: ProjectOptionsOverrides,
+ ) -> (Self, MainLoopCancellationToken) {
let (sender, receiver) = crossbeam_channel::bounded(10);
(
@@ -190,7 +201,7 @@ impl MainLoop {
sender: sender.clone(),
receiver,
watcher: None,
- cli_options,
+ project_options_overrides,
},
MainLoopCancellationToken { sender },
)
@@ -340,7 +351,7 @@ impl MainLoop {
MainLoopMessage::ApplyChanges(changes) => {
revision += 1;
// 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() {
watcher.update(db);
}
diff --git a/crates/ty/tests/cli.rs b/crates/ty/tests/cli.rs
index 344c41a9ff..a2892e4f71 100644
--- a/crates/ty/tests/cli.rs
+++ b/crates/ty/tests/cli.rs
@@ -1700,6 +1700,63 @@ fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> {
----- stderr -----
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(())
}
diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs
index 848117bf51..71c50ff6eb 100644
--- a/crates/ty/tests/file_watching.rs
+++ b/crates/ty/tests/file_watching.rs
@@ -10,7 +10,7 @@ use ruff_db::system::{
};
use ruff_db::{Db as _, Upcast};
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::value::{RangedValue, RelativePathBuf};
use ty_project::watch::{ChangeEvent, ProjectWatcher, directory_watcher};
@@ -164,8 +164,12 @@ impl TestCase {
Ok(all_events)
}
- fn apply_changes(&mut self, changes: Vec) {
- self.db.apply_changes(changes, None);
+ fn apply_changes(
+ &mut self,
+ changes: Vec,
+ project_options_overrides: Option<&ProjectOptionsOverrides>,
+ ) {
+ self.db.apply_changes(changes, project_options_overrides);
}
fn update_options(&mut self, options: Options) -> anyhow::Result<()> {
@@ -180,7 +184,7 @@ impl TestCase {
.context("Failed to write configuration")?;
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 {
watcher.update(&self.db);
@@ -476,7 +480,7 @@ fn new_file() -> anyhow::Result<()> {
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.");
@@ -499,7 +503,7 @@ fn new_ignored_file() -> anyhow::Result<()> {
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());
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- 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");
@@ -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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
let src_a_file = case.system_file(&src_a).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());
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')");
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert!(!foo.exists(case.db()));
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert!(!foo.exists(case.db()));
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
let init_file = case
.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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
// `import sub.a` should no longer resolve
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.
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
assert!(
@@ -989,7 +993,7 @@ fn directory_deleted() -> anyhow::Result<()> {
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
assert!(
@@ -1035,7 +1039,7 @@ fn search_path() -> anyhow::Result<()> {
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());
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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);
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert_eq!(
foo.permissions(case.db()),
@@ -1460,7 +1464,7 @@ mod unix {
let changes = case.take_watch_changes(event_for_file("baz.py"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert_eq!(
source_text(case.db(), baz_file).as_str(),
@@ -1473,7 +1477,7 @@ mod unix {
let changes = case.stop_watch(event_for_file("baz.py"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert_eq!(
source_text(case.db(), baz_file).as_str(),
@@ -1544,7 +1548,7 @@ mod unix {
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
// 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"));
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
assert_eq!(
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);
- case.apply_changes(changes);
+ case.apply_changes(changes, None);
// It should now pick up the outer project.
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"));
- 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);
@@ -1855,7 +1925,7 @@ fn rename_files_casing_only() -> anyhow::Result<()> {
}
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
assert_eq!(
diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs
index cf36a1946a..f2f72ae140 100644
--- a/crates/ty_project/src/db/changes.rs
+++ b/crates/ty_project/src/db/changes.rs
@@ -1,5 +1,5 @@
use crate::db::{Db, ProjectDatabase};
-use crate::metadata::options::Options;
+use crate::metadata::options::ProjectOptionsOverrides;
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
use crate::{Project, ProjectMetadata};
use std::collections::BTreeSet;
@@ -12,10 +12,18 @@ use rustc_hash::FxHashSet;
use ty_python_semantic::Program;
impl ProjectDatabase {
- #[tracing::instrument(level = "debug", skip(self, changes, cli_options))]
- pub fn apply_changes(&mut self, changes: Vec, cli_options: Option<&Options>) {
+ #[tracing::instrument(level = "debug", skip(self, changes, project_options_overrides))]
+ pub fn apply_changes(
+ &mut self,
+ changes: Vec,
+ project_options_overrides: Option<&ProjectOptionsOverrides>,
+ ) {
let mut project = self.project();
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 custom_stdlib_versions_path = program
.custom_stdlib_search_path(self)
@@ -42,6 +50,14 @@ impl ProjectDatabase {
tracing::trace!("Handle change: {:?}", change);
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!(
path.file_name(),
Some(".gitignore" | ".ignore" | "ty.toml" | "pyproject.toml")
@@ -170,10 +186,14 @@ impl ProjectDatabase {
}
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) => {
- if let Some(cli_options) = cli_options {
- metadata.apply_cli_options(cli_options.clone());
+ if let Some(cli_options) = options {
+ metadata.apply_options(cli_options);
}
if let Err(error) = metadata.apply_configuration_files(self.system()) {
diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs
index 2c79edf9de..6c7b95c256 100644
--- a/crates/ty_project/src/lib.rs
+++ b/crates/ty_project/src/lib.rs
@@ -5,7 +5,7 @@ use crate::walk::{ProjectFilesFilter, ProjectFilesWalker};
pub use db::{Db, ProjectDatabase};
use files::{Index, Indexed, IndexedFiles};
use metadata::settings::Settings;
-pub use metadata::{ProjectDiscoveryError, ProjectMetadata};
+pub use metadata::{ProjectMetadata, ProjectMetadataError};
use ruff_db::diagnostic::{
Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, create_parse_diagnostic,
create_unsupported_syntax_diagnostic,
diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs
index 2d866114ca..47896e5031 100644
--- a/crates/ty_project/src/metadata.rs
+++ b/crates/ty_project/src/metadata.rs
@@ -48,6 +48,29 @@ impl ProjectMetadata {
}
}
+ pub fn from_config_file(
+ path: SystemPathBuf,
+ system: &dyn System,
+ ) -> Result {
+ 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.
pub(crate) fn from_pyproject(
pyproject: PyProject,
@@ -106,11 +129,11 @@ impl ProjectMetadata {
pub fn discover(
path: &SystemPath,
system: &dyn System,
- ) -> Result {
+ ) -> Result {
tracing::debug!("Searching for a project in '{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 = None;
@@ -125,7 +148,7 @@ impl ProjectMetadata {
) {
Ok(pyproject) => Some(pyproject),
Err(error) => {
- return Err(ProjectDiscoveryError::InvalidPyProject {
+ return Err(ProjectMetadataError::InvalidPyProject {
path: pyproject_path,
source: Box::new(error),
});
@@ -144,7 +167,7 @@ impl ProjectMetadata {
) {
Ok(options) => options,
Err(error) => {
- return Err(ProjectDiscoveryError::InvalidTyToml {
+ return Err(ProjectMetadataError::InvalidTyToml {
path: ty_toml_path,
source: Box::new(error),
});
@@ -171,7 +194,7 @@ impl ProjectMetadata {
.and_then(|pyproject| pyproject.project.as_ref()),
)
.map_err(|err| {
- ProjectDiscoveryError::InvalidRequiresPythonConstraint {
+ ProjectMetadataError::InvalidRequiresPythonConstraint {
source: err,
path: pyproject_path,
}
@@ -185,7 +208,7 @@ impl ProjectMetadata {
let metadata =
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf())
.map_err(
- |err| ProjectDiscoveryError::InvalidRequiresPythonConstraint {
+ |err| ProjectMetadataError::InvalidRequiresPythonConstraint {
source: err,
path: pyproject_path,
},
@@ -249,7 +272,7 @@ impl ProjectMetadata {
}
/// 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));
}
@@ -282,7 +305,7 @@ impl ProjectMetadata {
}
#[derive(Debug, Error)]
-pub enum ProjectDiscoveryError {
+pub enum ProjectMetadataError {
#[error("project path '{0}' is not a directory")]
NotADirectory(SystemPathBuf),
@@ -303,6 +326,12 @@ pub enum ProjectDiscoveryError {
source: ResolveRequiresPythonError,
path: SystemPathBuf,
},
+
+ #[error("Error loading configuration file at {path}: {source}")]
+ ConfigurationFileError {
+ source: Box,
+ path: SystemPathBuf,
+ },
}
#[cfg(test)]
@@ -314,7 +343,7 @@ mod tests {
use ruff_db::system::{SystemPathBuf, TestSystem};
use ruff_python_ast::PythonVersion;
- use crate::{ProjectDiscoveryError, ProjectMetadata};
+ use crate::{ProjectMetadata, ProjectMetadataError};
#[test]
fn project_without_pyproject() -> anyhow::Result<()> {
@@ -1076,7 +1105,7 @@ expected `.`, `]`
}
#[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);
}
diff --git a/crates/ty_project/src/metadata/configuration_file.rs b/crates/ty_project/src/metadata/configuration_file.rs
index 4190d2cd8b..ad985cdff5 100644
--- a/crates/ty_project/src/metadata/configuration_file.rs
+++ b/crates/ty_project/src/metadata/configuration_file.rs
@@ -14,6 +14,25 @@ pub(crate) struct ConfigurationFile {
}
impl ConfigurationFile {
+ pub(crate) fn from_path(
+ path: SystemPathBuf,
+ system: &dyn System,
+ ) -> Result {
+ 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.
///
/// 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,
path: SystemPathBuf,
},
+ #[error("Failed to read `{path}`: {source}")]
+ FileReadError {
+ #[source]
+ source: std::io::Error,
+ path: SystemPathBuf,
+ },
}
diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs
index 0581684faf..2e089d334f 100644
--- a/crates/ty_project/src/metadata/options.rs
+++ b/crates/ty_project/src/metadata/options.rs
@@ -2,7 +2,7 @@ use crate::Db;
use crate::metadata::value::{RangedValue, RelativePathBuf, ValueSource, ValueSourceGuard};
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, Severity, Span};
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_python_ast::PythonVersion;
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,
+ pub options: Options,
+}
+
+impl ProjectOptionsOverrides {
+ pub fn new(config_file_override: Option, options: Options) -> Self {
+ Self {
+ config_file_override,
+ options,
+ }
+ }
+}