[ty] Add `--force-exclude` option (#22076)

This commit is contained in:
Micha Reiser 2025-12-20 10:03:41 +01:00 committed by GitHub
parent 2a959ef3f2
commit 5b475b45aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 176 additions and 6 deletions

1
crates/ty/docs/cli.md generated
View File

@ -56,6 +56,7 @@ over all configuration files.</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>
<p>This is an advanced option that should usually only be used for first-party or third-party modules that are not installed into your Python environment in a conventional way. Use <code>--python</code> to point ty to your Python environment if it is in an unusual location.</p>
</dd><dt id="ty-check--force-exclude"><a href="#ty-check--force-exclude"><code>--force-exclude</code></a></dt><dd><p>Enforce exclusions, even for paths passed to ty directly on the command-line. Use <code>--no-force-exclude</code> to disable</p>
</dd><dt id="ty-check--help"><a href="#ty-check--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Print help (see a summary with '-h')</p>
</dd><dt id="ty-check--ignore"><a href="#ty-check--ignore"><code>--ignore</code></a> <i>rule</i></dt><dd><p>Disables the rule. Can be specified multiple times.</p>
</dd><dt id="ty-check--no-progress"><a href="#ty-check--no-progress"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>

View File

@ -154,6 +154,17 @@ pub(crate) struct CheckCommand {
#[clap(long, overrides_with("respect_ignore_files"), hide = true)]
no_respect_ignore_files: bool,
/// Enforce exclusions, even for paths passed to ty directly on the command-line.
/// Use `--no-force-exclude` to disable.
#[arg(
long,
overrides_with("no_force_exclude"),
help_heading = "File selection"
)]
force_exclude: bool,
#[clap(long, overrides_with("force_exclude"), hide = true)]
no_force_exclude: bool,
/// Glob patterns for files to exclude from type checking.
///
/// Uses gitignore-style syntax to exclude files and directories from type checking.
@ -178,6 +189,10 @@ pub(crate) struct CheckCommand {
}
impl CheckCommand {
pub(crate) fn force_exclude(&self) -> bool {
resolve_bool_arg(self.force_exclude, self.no_progress).unwrap_or_default()
}
pub(crate) fn into_options(self) -> Options {
let rules = if self.rules.is_empty() {
None
@ -435,3 +450,12 @@ impl ConfigsArg {
self.0
}
}
fn resolve_bool_arg(yes: bool, no: bool) -> Option<bool> {
match (yes, no) {
(true, false) => Some(true),
(false, true) => Some(false),
(false, false) => None,
(..) => unreachable!("Clap should make this impossible"),
}
}

View File

@ -118,6 +118,7 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
.config_file
.as_ref()
.map(|path| SystemPath::absolute(path, &cwd));
let force_exclude = args.force_exclude();
let mut project_metadata = match &config_file {
Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?,
@ -130,11 +131,13 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
project_metadata.apply_overrides(&project_options_overrides);
let mut db = ProjectDatabase::new(project_metadata, system)?;
let project = db.project();
project.set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose);
project.set_force_exclude(&mut db, force_exclude);
db.project()
.set_verbose(&mut db, verbosity >= VerbosityLevel::Verbose);
if !check_paths.is_empty() {
db.project().set_included_paths(&mut db, check_paths);
project.set_included_paths(&mut db, check_paths);
}
let (main_loop, main_loop_cancellation_token) =

View File

@ -589,6 +589,128 @@ fn explicit_path_overrides_exclude() -> anyhow::Result<()> {
Ok(())
}
/// Test behavior when explicitly checking a path that matches an exclude pattern and `--force-exclude` is provided
#[test]
fn explicit_path_overrides_exclude_force_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"tests/generated.py",
r#"
print(dist_undefined_var) # error: unresolved-reference
"#,
),
(
"dist/other.py",
r#"
print(other_undefined_var) # error: unresolved-reference
"#,
),
(
"ty.toml",
r#"
[src]
exclude = ["tests/generated.py"]
"#,
),
])?;
// Explicitly checking a file in an excluded directory should still check that file
assert_cmd_snapshot!(case.command().arg("tests/generated.py").arg("src/main.py"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `undefined_var` used when not defined
--> src/main.py:2:7
|
2 | print(undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
error[unresolved-reference]: Name `dist_undefined_var` used when not defined
--> tests/generated.py:2:7
|
2 | print(dist_undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 2 diagnostics
----- stderr -----
");
// Except when `--force-exclude` is set.
assert_cmd_snapshot!(case.command().arg("tests/generated.py").arg("src/main.py").arg("--force-exclude"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `undefined_var` used when not defined
--> src/main.py:2:7
|
2 | print(undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 1 diagnostic
----- stderr -----
");
// Explicitly checking the entire excluded directory should check all files in it
assert_cmd_snapshot!(case.command().arg("dist/").arg("src/main.py"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `other_undefined_var` used when not defined
--> dist/other.py:2:7
|
2 | print(other_undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
error[unresolved-reference]: Name `undefined_var` used when not defined
--> src/main.py:2:7
|
2 | print(undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 2 diagnostics
----- stderr -----
");
// Except when using `--force-exclude`
assert_cmd_snapshot!(case.command().arg("dist/").arg("src/main.py").arg("--force-exclude"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `undefined_var` used when not defined
--> src/main.py:2:7
|
2 | print(undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 1 diagnostic
----- stderr -----
");
Ok(())
}
#[test]
fn cli_and_configuration_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([

View File

@ -115,6 +115,10 @@ pub struct Project {
#[default]
verbose_flag: bool,
/// Whether to enforce exclusion rules even to files explicitly passed to ty on the command line.
#[default]
force_exclude_flag: bool,
}
/// A progress reporter.
@ -380,6 +384,16 @@ impl Project {
self.verbose_flag(db)
}
pub fn set_force_exclude(self, db: &mut dyn Db, force: bool) {
if self.force_exclude_flag(db) != force {
self.set_force_exclude_flag(db).to(force);
}
}
pub fn force_exclude(self, db: &dyn Db) -> bool {
self.force_exclude_flag(db)
}
/// Returns the paths that should be checked.
///
/// The default is to check the entire project in which case this method returns

View File

@ -111,6 +111,8 @@ pub(crate) struct ProjectFilesWalker<'a> {
walker: WalkDirectoryBuilder,
filter: ProjectFilesFilter<'a>,
force_exclude: bool,
}
impl<'a> ProjectFilesWalker<'a> {
@ -158,7 +160,11 @@ impl<'a> ProjectFilesWalker<'a> {
walker = walker.add(path);
}
Some(Self { walker, filter })
Some(Self {
walker,
filter,
force_exclude: db.project().force_exclude(db),
})
}
/// Walks the project paths and collects the paths of all files that
@ -179,7 +185,7 @@ impl<'a> ProjectFilesWalker<'a> {
// Skip excluded directories unless they were explicitly passed to the walker
// (which is the case passed to `ty check <paths>`).
if entry.file_type().is_directory() {
if entry.depth() > 0 {
if entry.depth() > 0 || self.force_exclude {
let directory_included = filter
.is_directory_included(entry.path(), GlobFilterCheckMode::TopDown);
return match directory_included {
@ -218,7 +224,7 @@ impl<'a> ProjectFilesWalker<'a> {
// For all files, except the ones that were explicitly passed to the walker (CLI),
// check if they're included in the project.
if entry.depth() > 0 {
if entry.depth() > 0 || self.force_exclude {
match filter
.is_file_included(entry.path(), GlobFilterCheckMode::TopDown)
{