[ty] File inclusion and exclusion (#18498)

This commit is contained in:
Micha Reiser 2025-06-12 19:07:31 +02:00 committed by GitHub
parent 3c6c017950
commit 1f27d53fd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2728 additions and 159 deletions

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

@ -51,6 +51,8 @@ over all configuration files.</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--exclude"><a href="#ty-check--exclude"><code>--exclude</code></a> <i>exclude</i></dt><dd><p>Glob patterns for files to exclude from type checking.</p>
<p>Uses gitignore-style syntax to exclude files and directories from type checking. Supports patterns like <code>tests/</code>, <code>*.tmp</code>, <code>**/__pycache__/**</code>.</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--help"><a href="#ty-check--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Print help (see a summary with '-h')</p>

View file

@ -153,6 +153,67 @@ typeshed = "/path/to/custom/typeshed"
## `src`
#### `exclude`
A list of file and directory patterns to exclude from type checking.
Patterns follow a syntax similar to `.gitignore`:
- `./src/` matches only a directory
- `./src` matches both files and directories
- `src` matches files or directories named `src` anywhere in the tree (e.g. `./src` or `./tests/src`)
- `*` matches any (possibly empty) sequence of characters (except `/`).
- `**` matches zero or more path components.
This sequence **must** form a single path component, so both `**a` and `b**` are invalid and will result in an error.
A sequence of more than two consecutive `*` characters is also invalid.
- `?` matches any single character except `/`
- `[abc]` matches any character inside the brackets. Character sequences can also specify ranges of characters, as ordered by Unicode,
so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.
- `!pattern` negates a pattern (undoes the exclusion of files that would otherwise be excluded)
By default, the following directories are excluded:
- `.bzr`
- `.direnv`
- `.eggs`
- `.git`
- `.git-rewrite`
- `.hg`
- `.mypy_cache`
- `.nox`
- `.pants.d`
- `.pytype`
- `.ruff_cache`
- `.svn`
- `.tox`
- `.venv`
- `__pypackages__`
- `_build`
- `buck-out`
- `dist`
- `node_modules`
- `venv`
You can override any default exclude by using a negated pattern. For example,
to re-include `dist` use `exclude = ["!dist"]`
**Default value**: `null`
**Type**: `list[str]`
**Example usage** (`pyproject.toml`):
```toml
[tool.ty.src]
exclude = [
"generated",
"*.proto",
"tests/fixtures/**",
"!tests/fixtures/important.py" # Include this one file
]
```
---
#### `respect-ignore-files`
Whether to automatically exclude files that are ignored by `.ignore`,

View file

@ -5,7 +5,9 @@ use clap::{ArgAction, ArgMatches, Error, Parser};
use ruff_db::system::SystemPathBuf;
use ty_project::combine::Combine;
use ty_project::metadata::options::{EnvironmentOptions, Options, SrcOptions, TerminalOptions};
use ty_project::metadata::value::{RangedValue, RelativePathBuf, ValueSource};
use ty_project::metadata::value::{
RangedValue, RelativeExcludePattern, RelativePathBuf, ValueSource,
};
use ty_python_semantic::lint;
#[derive(Debug, Parser)]
@ -148,6 +150,13 @@ pub(crate) struct CheckCommand {
respect_ignore_files: Option<bool>,
#[clap(long, overrides_with("respect_ignore_files"), hide = true)]
no_respect_ignore_files: bool,
/// Glob patterns for files to exclude from type checking.
///
/// Uses gitignore-style syntax to exclude files and directories from type checking.
/// Supports patterns like `tests/`, `*.tmp`, `**/__pycache__/**`.
#[arg(long, help_heading = "File selection")]
exclude: Option<Vec<String>>,
}
impl CheckCommand {
@ -195,6 +204,12 @@ impl CheckCommand {
}),
src: Some(SrcOptions {
respect_ignore_files,
exclude: self.exclude.map(|excludes| {
excludes
.iter()
.map(|exclude| RelativeExcludePattern::cli(exclude))
.collect()
}),
..SrcOptions::default()
}),
rules,

View file

@ -0,0 +1,726 @@
use insta_cmd::assert_cmd_snapshot;
use crate::CliTest;
/// Test exclude CLI argument functionality
#[test]
fn exclude_argument() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"tests/test_main.py",
r#"
print(another_undefined_var) # error: unresolved-reference
"#,
),
(
"temp_file.py",
r#"
print(temp_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Test that exclude argument is recognized and works
assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/"), @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 `temp_undefined_var` used when not defined
--> temp_file.py:2:7
|
2 | print(temp_undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Test multiple exclude patterns
assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/").arg("--exclude").arg("temp_*.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
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(())
}
/// Test configuration file include functionality
#[test]
fn configuration_include() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"tests/test_main.py",
r#"
print(another_undefined_var) # error: unresolved-reference
"#,
),
(
"other.py",
r#"
print(other_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Test include via configuration - should only check included files
case.write_file(
"ty.toml",
r#"
[src]
include = ["src"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Test multiple include patterns via configuration
case.write_file(
"ty.toml",
r#"
[src]
include = ["src", "other.py"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `other_undefined_var` used when not defined
--> 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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Test configuration file exclude functionality
#[test]
fn configuration_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"tests/test_main.py",
r#"
print(another_undefined_var) # error: unresolved-reference
"#,
),
(
"temp_file.py",
r#"
print(temp_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Test exclude via configuration
case.write_file(
"ty.toml",
r#"
[src]
exclude = ["tests/"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @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 `temp_undefined_var` used when not defined
--> temp_file.py:2:7
|
2 | print(temp_undefined_var) # error: unresolved-reference
| ^^^^^^^^^^^^^^^^^^
|
info: rule `unresolved-reference` is enabled by default
Found 2 diagnostics
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Test multiple exclude patterns via configuration
case.write_file(
"ty.toml",
r#"
[src]
exclude = ["tests/", "temp_*.py"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Test that exclude takes precedence over include in configuration
#[test]
fn exclude_precedence_over_include() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"src/test_helper.py",
r#"
print(helper_undefined_var) # error: unresolved-reference
"#,
),
(
"other.py",
r#"
print(other_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Include all src files but exclude test files - exclude should win
case.write_file(
"ty.toml",
r#"
[src]
include = ["src"]
exclude = ["test_*.py"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Test that CLI exclude overrides configuration include
#[test]
fn exclude_argument_precedence_include_argument() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"tests/test_main.py",
r#"
print(another_undefined_var) # error: unresolved-reference
"#,
),
(
"other.py",
r#"
print(other_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Configuration includes all files, but CLI excludes tests
case.write_file(
"ty.toml",
r#"
[src]
include = ["src/", "tests/"]
"#,
)?;
assert_cmd_snapshot!(case.command().arg("--exclude").arg("tests/"), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
"###);
Ok(())
}
/// Test that default excludes can be removed using negated patterns
#[test]
fn remove_default_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"dist/generated.py",
r#"
print(another_undefined_var) # error: unresolved-reference
"#,
),
])?;
// By default, 'dist' directory should be excluded (see default excludes)
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Now override the default exclude by using a negated pattern to re-include 'dist'
case.write_file(
"ty.toml",
r#"
[src]
exclude = ["!dist"]
"#,
)?;
assert_cmd_snapshot!(case.command(), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `another_undefined_var` used when not defined
--> dist/generated.py:2:7
|
2 | print(another_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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Test that configuration excludes can be removed via CLI negation
#[test]
fn cli_removes_config_exclude() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"build/output.py",
r#"
print(build_undefined_var) # error: unresolved-reference
"#,
),
])?;
// Configuration excludes the build directory
case.write_file(
"ty.toml",
r#"
[src]
exclude = ["build/"]
"#,
)?;
// Verify that build/ is excluded by configuration
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Now remove the configuration exclude via CLI negation
assert_cmd_snapshot!(case.command().arg("--exclude").arg("!build/"), @r"
success: false
exit_code: 1
----- stdout -----
error[unresolved-reference]: Name `build_undefined_var` used when not defined
--> build/output.py:2:7
|
2 | print(build_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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
Ok(())
}
/// Test behavior when explicitly checking a path that matches an exclude pattern
#[test]
fn explicit_path_overrides_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"]
"#,
),
])?;
// dist is excluded by default and `tests/generated` is excluded in the project, so only src/main.py should be checked
assert_cmd_snapshot!(case.command(), @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 -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Explicitly checking a file in an excluded directory should still check that file
assert_cmd_snapshot!(case.command().arg("tests/generated.py"), @r"
success: false
exit_code: 1
----- stdout -----
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 1 diagnostic
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
");
// Explicitly checking the entire excluded directory should check all files in it
assert_cmd_snapshot!(case.command().arg("dist/"), @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
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(())
}
#[test]
fn invalid_include_pattern() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"ty.toml",
r#"
[src]
include = [
"src/**test/"
]
"#,
),
])?;
// By default, dist/ is excluded, so only src/main.py should be checked
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob]: Invalid include pattern
--> ty.toml:4:5
|
2 | [src]
3 | include = [
4 | "src/**test/"
| ^^^^^^^^^^^^^ Too many stars at position 5 in glob: `src/**test/`
5 | ]
|
"#);
Ok(())
}
#[test]
fn invalid_include_pattern_concise_output() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"ty.toml",
r#"
[src]
include = [
"src/**test/"
]
"#,
),
])?;
// By default, dist/ is excluded, so only src/main.py should be checked
assert_cmd_snapshot!(case.command().arg("--output-format").arg("concise"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob] ty.toml:4:5: Invalid include pattern: Too many stars at position 5 in glob: `src/**test/`
");
Ok(())
}
#[test]
fn invalid_exclude_pattern() -> anyhow::Result<()> {
let case = CliTest::with_files([
(
"src/main.py",
r#"
print(undefined_var) # error: unresolved-reference
"#,
),
(
"ty.toml",
r#"
[src]
exclude = [
"../src"
]
"#,
),
])?;
// By default, dist/ is excluded, so only src/main.py should be checked
assert_cmd_snapshot!(case.command(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
ty failed
Cause: error[invalid-glob]: Invalid exclude pattern
--> ty.toml:4:5
|
2 | [src]
3 | exclude = [
4 | "../src"
| ^^^^^^^^ The parent directory operator (`..`) at position 1 is not allowed in glob: `../src`
5 | ]
|
"#);
Ok(())
}

View file

@ -1,5 +1,6 @@
mod config_option;
mod exit_code;
mod file_selection;
mod python_environment;
mod rule_selection;

View file

@ -254,12 +254,12 @@ fn configuration_unknown_rules() -> anyhow::Result<()> {
success: true
exit_code: 0
----- stdout -----
warning[unknown-rule]
warning[unknown-rule]: Unknown lint rule `division-by-zer`
--> pyproject.toml:3:1
|
2 | [tool.ty.rules]
3 | division-by-zer = "warn" # incorrect rule name
| ^^^^^^^^^^^^^^^ Unknown lint rule `division-by-zer`
| ^^^^^^^^^^^^^^^
|
Found 1 diagnostic