Skip to content

Comments

[ty] Allow overriding rules for specific files#18648

Merged
MichaReiser merged 16 commits intomainfrom
micha/overrides
Jun 15, 2025
Merged

[ty] Allow overriding rules for specific files#18648
MichaReiser merged 16 commits intomainfrom
micha/overrides

Conversation

@MichaReiser
Copy link
Member

@MichaReiser MichaReiser commented Jun 12, 2025

Summary

This PR adds a new [[overrides]] configuration section that allows users to toggle rules at a file/path level.

[tool.ty.rules]
division-by-zero = "error"

# First override: all test files
[[tool.ty.overrides]]
include = ["tests/**"]

[tool.ty.overrides.rules]
division-by-zero = "warn"

# Second override: specific test file (takes precedence)
[[tool.ty.overrides]]
include = ["tests/important.py"]

[tool.ty.overrides.rules]
division-by-zero = "ignore"

A file can match zero or many overrides sections. Later overrides take precedence over earlier overrides. Overrides inherit from the global configuration.

I thought long whether we should allow at most one match (always taking the last) or multiple matches. I did some ecosystem research to see how per-file-ignores is used. The most common pattern that I've found is too disable some rules for a sub directory and within that subdirectory, disable more rules for specific files. From airflow:

"src/tests_common/*" = ["S101", "TRY002"]
# Test compat imports banned imports to allow testing against older airflow versions
"src/tests_common/test_utils/compat.py" = ["TID251", "F401"]
"src/tests_common/pytest_plugin.py" = ["F811"]

This is why I ultimately decided to allow multiple matches. This complicates the implementation a bit but not too much. The main downside is that it will make it harder to warn if combining two overrides results in incompatible options because the validation happens lazily.

The other downside is that it there's no real way override an existing match other than overriding every single. E.g. when the user configuration has an overrides that a project wants to override. I think that this is less common and we could consider introducing an option that would mark an overrides as the "root", so that ty doesn't apply any more overrides.

Closes astral-sh/ty#178

Glob syntax

I decided to use the same include and exclude syntax as we use at the src level. Technically, a single include option that allows both positive and negative patterns would be enough (and negative excludes doesn't really make sense). However, I opted for the src syntax because I think a familiar syntax is important: It woudl be confusing if we allow different glob patterns in different include fields.

Naming

I went with overrides, similar to what mypy uses (although mypy filters on modules not paths). I think the name is fine but overrides are something entirely different in uv.

I'm curious to hear more opinions. Alternatives are:

  • files
  • sub_configurations
  • ...?

@MichaReiser MichaReiser added configuration Related to settings and configuration ty Multi-file analysis & type inference labels Jun 12, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Jun 12, 2025

mypy_primer results

No ecosystem changes detected ✅

@github-actions
Copy link
Contributor

github-actions bot commented Jun 12, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

Base automatically changed from micha/include-exclude to main June 12, 2025 17:07
@MichaReiser MichaReiser force-pushed the micha/overrides branch 2 times, most recently from 92926ae to e1c3f54 Compare June 12, 2025 18:17

impl Rules {
/// Convert the rules to a `RuleSelection` with diagnostics.
pub fn to_rule_selection(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only moved this code

Comment on lines 717 to 732
if include_patterns.is_empty() {
// An override with an empty include `[]` won't match any files.
let mut diagnostic = OptionDiagnostic::new(
DiagnosticId::EmptyInclude,
"Empty include doesn't match any files".to_string(),
Severity::Warning,
)
.sub(SubDiagnostic::new(
Severity::Info,
"Please open an issue on the ty repository and share the pattern that caused the error.",
)))
})?;

let mut excludes = ExcludeFilterBuilder::new();

// Add the default excludes first, so that a user can override them with a negated exclude pattern.
for pattern in [
".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",
] {
PortableGlobPattern::parse(pattern, true)
.and_then(|exclude| Ok(excludes.add(&exclude.into_absolute(""))?))
.unwrap_or_else(|err| {
panic!(
"Expected default exclude to be valid glob but adding it failed with: {err}"
"Remove the `include` option to match all files or add a pattern to match specific files",
));

// Add source annotation if we have source information
if let Some(source_file) = include_patterns.source().file() {
if let Ok(file) = system_path_to_file(db.upcast(), source_file) {
let annotation = Annotation::primary(
Span::from(file).with_optional_range(include_patterns.range()),
)
});
.message("This `include` option is empty");
diagnostic = diagnostic.with_annotation(Some(annotation));
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new

@MichaReiser MichaReiser marked this pull request as ready for review June 13, 2025 11:11
Copy link
Member

@BurntSushi BurntSushi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks great! I feel like the semantics here are very intuitive and also very flexible. I also agree with using two lists to make for a consistent UX.

/// An `include` glob without any patterns.
///
/// ## Why is this bad?
/// An `include` glob without any patterns won't match any files. This is probably a mistake and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// An `include` glob without any patterns won't match any files. This is probably a mistake and
/// An `include` setting without any globs won't match any files. This is probably a mistake and

.iter()
.map(|segment| &segment.ident);

if itertools::equal(path_idents, path) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can just do path_idents.eq(path) here? Via Iterator::eq.

so e.g. `[0-9]` specifies any character between `0` and `9` inclusive. An unclosed bracket is invalid.

Unlike `exclude`, all paths are anchored relative to the project root (`src` only
matches `<project_root>/src` and not `<project_root>/test/src`).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this part is in contradiction with the third bullet point above.

Also, assuming this part is right and the bullet above is wrong, can you say more about why include and exclude are different in this regard? I feel like they should be treated the same?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only explaining why they're different. Anchoring include paths has the advantage that we can skip directories that we know can't match any pattern. However, this only works for as long as there's no pattern starting with a wildcard pattern like **/src.

The same problem doesn't exist with exclude.

Given that most users probably mean ./src when writing source in an include list and the performance optimization that anchoring allows us to do, this seemed the better default.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah hmmm. I guess that does mean users have to write **/*.pyi for an include rule when they want different rules for, say, stub files. I feel like most users are going to expect that *.pyi should work on its own. But I agree that this semantic implies there is some unavoidable costs imposed by more traversals. Because of that, I think I like what you settled on. But it might be worth highlighting in the docs somewhere that, e.g., *.pyi (or some other example) might not do what you expect.

The other thing I might suggest is if we can make exclude consistent with include? I realize that it isn't needed as an optimization, but maybe a consistent semantic across include and exclude is less confusing? If users use *.pyi to exclude stub files anywhere, then they might expect *.pyi to work in include for stub files anywhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I sort of like it because it allows you to write exclude's in a more concise way. It also is the same behavior as uv's build backend (with the exception that uv doesn't anchor any exclude patterns, whereas we follow the gitignore semantic where any path with a / is anchored)

I do acknowledge that the *.pyi example might be somewhat suprising but not more than src.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I guess I just think the consistency here we'll lead to less overall confusion, even at the expense of concision.

But I think reasonable people can disagree here and I'm happy to defer to you. :-)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's hope some more folks chime in :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a need to weigh in on this, but since it was requested, my preference would be for consistency over concision. I think it's confusing if include and exclude work differently, and this will trip people up. I really don't think it's a problem if you have to write **/*.pyi in your exclude pattern instead of just *.pyi -- I think that's better than it working one way in include and a different way in exclude.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also agree with Carl and Andrew on this one!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, I'll change this in a separate PR because this behavior existed before this PR (it only surfaced in this PR because I fixed the docs)

@sharkdp sharkdp removed their request for review June 14, 2025 07:39
@MichaReiser MichaReiser merged commit 3a430fa into main Jun 15, 2025
35 checks passed
@MichaReiser MichaReiser deleted the micha/overrides branch June 15, 2025 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

configuration Related to settings and configuration ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

File-level configuration

4 participants