[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

@ -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"] }

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

@ -47,7 +47,9 @@ ty check [OPTIONS] [PATH]...
overriding a specific configuration option.</p>
<p>Overrides of individual settings using this option always take precedence
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--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>

View file

@ -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<SystemPathBuf>,
/// The format to use for printing diagnostic messages.
#[arg(long)]
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::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<ExitStatus> {
.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<ExitStatus> {
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<ProjectWatcher>,
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);
}

View file

@ -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(())
}

View file

@ -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<ChangeEvent>) {
self.db.apply_changes(changes, None);
fn apply_changes(
&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<()> {
@ -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!(