Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fallback to requires-python in certain cases when target-version is not found #16319

Merged
merged 36 commits into from
Mar 13, 2025

Conversation

dylwil3
Copy link
Collaborator

@dylwil3 dylwil3 commented Feb 22, 2025

Summary

This PR introduces the following modifications in configuration resolution:

  1. In the event where we are reading in a configuration file myconfig with no target-version specified, we will search for a pyproject.toml in the same directory as myconfig and see if it has a requires-python field. If so, we will use that as the target-version.

  2. In the event where...

  • we have not used the flag --isolated, and
  • we have not found any configuration file, and
  • we have not passed --config specifying a target version

then we will search for a pyproject.toml file with required-python in an ancestor directory and use that if we find it.

We've also added some debug logs to indicate which of these paths is taken.

Implementation

Two small things:

  1. I have chosen a method that will sometimes re-parse a pyproject.toml file that has already been parsed at some earlier stage in the resolution process. It seemed like avoiding that would require more drastic changes - but maybe there is a clever way that I'm not seeing!
  2. When searching for these fallbacks, I suppress any errors that may occur when parsing pyproject.tomls rather than propagate them. The reasoning here is that we have already found or decided upon a perfectly good configuration, and this is just a "bonus" to find a better guess for the target version.

Closes #14813, #16662

Testing

The linked issue contains a repo for reproducing the behavior, which we used for a manual test:

ruff-F821-repro on  mainuvx ruff check --no-cache
hello.py:9:11: F821 Undefined name `anext`. Consider specifying `requires-python = ">= 3.10"` or `tool.ruff.target-version = "py310"` in your `pyproject.toml` file.
  |
8 | async def main():
9 |     print(anext(g()))
  |           ^^^^^ F821
  |

Found 1 error.

ruff-F821-repro on  main../ruff/target/debug/ruff check --no-cache
All checks passed!

In addition, we've added snapshot tests with the CLI output in some examples. Please let me know if there are some additional scenarios you'd like me to add tests for!

@dylwil3 dylwil3 added breaking Breaking API change configuration Related to settings and configuration do-not-merge Do not merge this pull request labels Feb 22, 2025
@dylwil3 dylwil3 added this to the v0.10 milestone Feb 22, 2025
Copy link
Contributor

github-actions bot commented Feb 22, 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.

@dylwil3 dylwil3 force-pushed the requires-python branch 2 times, most recently from ad3eedb to 0c589b7 Compare February 22, 2025 19:58
Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

Thanks for looking into this.

We may need to restructure the code more. I believe the current implementation also applies the fallback for inherited and maybe even user configurations.

We only want the fallback to apply to top-level project configurations.

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Feb 23, 2025

We only want the fallback to apply to top-level project configurations.

Since configurations are resolved relative the to the file being checked, I just want to confirm that what you mean here is something like the below. (Maybe the implementation is not what you'd prefer, but is the logic doing what you meant?)

diff --git a/crates/ruff_workspace/src/pyproject.rs b/crates/ruff_workspace/src/pyproject.rs
index 9d9bc4199..cbc3958ab 100644
--- a/crates/ruff_workspace/src/pyproject.rs
+++ b/crates/ruff_workspace/src/pyproject.rs
@@ -152,7 +152,10 @@ pub fn find_user_settings_toml() -> Option<PathBuf> {
 }
 
 /// Load `Options` from a `pyproject.toml` or `ruff.toml` file.
-pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
+pub(super) fn load_options<P: AsRef<Path>>(
+    path: P,
+    version_strategy: TargetVersionStrategy,
+) -> Result<Options> {
     if path.as_ref().ends_with("pyproject.toml") {
         let pyproject = parse_pyproject_toml(&path)?;
         let mut ruff = pyproject
@@ -172,16 +175,21 @@ pub(super) fn load_options<P: AsRef<Path>>(path: P) -> Result<Options> {
         if let Ok(ref mut ruff) = ruff {
             if ruff.target_version.is_none() {
                 debug!("No `target-version` found in `ruff.toml`");
-                if let Some(dir) = path.as_ref().parent() {
-                    let fallback = get_fallback_target_version(dir);
-                    if fallback.is_some() {
-                        debug!(
+                match version_strategy {
+                    TargetVersionStrategy::Standard => {}
+                    TargetVersionStrategy::RequiresPythonFallback => {
+                        if let Some(dir) = path.as_ref().parent() {
+                            let fallback = get_fallback_target_version(dir);
+                            if fallback.is_some() {
+                                debug!(
                             "Deriving `target-version` from `requires-python` in `pyproject.toml`"
                         );
-                    } else {
-                        debug!("No `pyproject.toml` with `requires-python` in same directory; `target-version` unspecified");
+                            } else {
+                                debug!("No `pyproject.toml` with `requires-python` in same directory; `target-version` unspecified");
+                            }
+                            ruff.target_version = fallback;
+                        }
                     }
-                    ruff.target_version = fallback;
                 }
             }
         }
@@ -236,6 +244,13 @@ fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option
     PythonVersion::iter().find(|version| Version::from(*version) == minimum_version)
 }
 
+#[derive(Debug, Default)]
+pub(super) enum TargetVersionStrategy {
+    #[default]
+    Standard,
+    RequiresPythonFallback,
+}
+
 #[cfg(test)]
 mod tests {
     use std::fs;
diff --git a/crates/ruff_workspace/src/resolver.rs b/crates/ruff_workspace/src/resolver.rs
index f550f810f..ef10119c3 100644
--- a/crates/ruff_workspace/src/resolver.rs
+++ b/crates/ruff_workspace/src/resolver.rs
@@ -23,7 +23,7 @@ use ruff_linter::package::PackageRoot;
 use ruff_linter::packaging::is_package;
 
 use crate::configuration::Configuration;
-use crate::pyproject::settings_toml;
+use crate::pyproject::{settings_toml, TargetVersionStrategy};
 use crate::settings::Settings;
 use crate::{pyproject, FileResolverSettings};
 
@@ -319,7 +319,15 @@ pub fn resolve_configuration(
         }
 
         // Resolve the current path.
-        let options = pyproject::load_options(&path).with_context(|| {
+        let version_strategy = if configurations.is_empty() {
+            TargetVersionStrategy::RequiresPythonFallback
+        } else {
+            // For inherited configurations, we do not attempt to
+            // fill missing `target-version` with `requires-python`
+            // from a nearby `pyproject.toml`
+            TargetVersionStrategy::Standard
+        };
+        let options = pyproject::load_options(&path, version_strategy).with_context(|| {
             if configurations.is_empty() {
                 format!(
                     "Failed to load configuration `{path}`",

@MichaReiser
Copy link
Member

Uhm, hard to say because I'm mostly unfamiliar with this part of Ruff.

Different strategies could be a solution or we start using different methods based on what configuration we're resolving (I think that's what we do in Red Knot)

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Feb 23, 2025

I guess what I'm trying to say is: what do you mean by "project root"? It doesn't seem to me like that's really a first-class notion in Ruff in the same way that it kind of has to be in uv or Red Knot, as far as I can tell.

Probably I'm just not understanding the design spec here - sorry! So far I think I understand these requirements:

  • (from the issue) If we can't find any configuration but there's a pyproject.toml without a tool.ruff section, don't ignore it and instead use the requires-python from there
  • (from the issue) If we find a ruff.toml without a target-version, fallback to the pyproject.toml's requires-python at the same level (if there's any).
  • Do not use the fallback when resolving user-level configuration

But I'm not understanding the requirement around the project root. Would it be possible to give an example of the behavior you're looking for?

@MichaReiser
Copy link
Member

That's a good point and using the term project root was probably more confusing than helpful (because Ruff doesn't know that concept).

What I meant is that this fallback shouldn't apply to user-level configurations or an extended (extend = "dont_fallback_to_pyproject_toml_here.toml") configuration.

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Feb 23, 2025

Excellent, thank you for clarifying! That's what I suspected (and is handled by the diff above). I'll tackle the user-config and inherited config adjustments now. Sorry for the confusion!

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Feb 23, 2025

@dhruvmanila no urgency here, but before we add this to v0.10 it'd be great if you could double-check that the changes to ruff server make sense. The hope is that the changes will ensure that the settings will be resolved the same way whether you are running the server or passing in paths on the command line.

@dhruvmanila
Copy link
Member

@dhruvmanila no urgency here, but before we add this to v0.10 it'd be great if you could double-check that the changes to ruff server make sense. The hope is that the changes will ensure that the settings will be resolved the same way whether you are running the server or passing in paths on the command line.

Yeah, I think it makes sense although I'd also test it out in an editor context. I can add it to my todo but I was wondering if you had done any testing in an editor (VS Code or any other)?

debug!("Deriving `target-version` from found `requires-python`");
}
config.target_version = fallback.map(Into::into);
}
let settings = config.into_settings(&path_dedot::CWD)?;
Copy link
Member

Choose a reason for hiding this comment

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

Can we extract the path into a variable and use it both in find_fallback_target_version and when calling into_settings. It required me few cycles to understand whether path_dedot::CWD is correct but I then noticed that it's already what into_setting uses

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure, it depends on what behavior we want.

The way I have it currently set up I'm trying to make the behavior change as little as possible, so I've kept the settings resolution path the same no matter what, but attempted to find a fallback version using the same logic as we did when searching for a user configuration file. That logic would start from the stdin filename directory if it was passed in.

In other words, I don't think I can extract a path variable here since I may be passing a different value to find_fallback_target_version than I am to into_settings.

Comment on lines 185 to 186
"Deriving `target-version` from `requires-python` in `pyproject.toml`"
);
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
"Deriving `target-version` from `requires-python` in `pyproject.toml`"
);
"Deriving `target-version` from `requires-python` in `pyproject.toml`"
);

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

instead I made the spacing match the debug call in the other branch: ffd2dbf

@@ -699,7 +732,7 @@ pub fn python_file_at_path(
for ancestor in path.ancestors() {
if let Some(pyproject) = settings_toml(ancestor)? {
let (root, settings) =
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer)?;
resolve_scoped_settings(&pyproject, Relativity::Parent, transformer, None)?;
Copy link
Member

Choose a reason for hiding this comment

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

It's not clear to me why using None here is correct (or when using None is correct in general). Can you tell me a bit more about it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tried to clarify here (replacing None with new variant Unknown which signals that the configuration origin is unknown to the caller: af82f19

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Feb 28, 2025

@dhruvmanila no urgency here, but before we add this to v0.10 it'd be great if you could double-check that the changes to ruff server make sense. The hope is that the changes will ensure that the settings will be resolved the same way whether you are running the server or passing in paths on the command line.

Yeah, I think it makes sense although I'd also test it out in an editor context. I can add it to my todo but I was wondering if you had done any testing in an editor (VS Code or any other)?

I haven't tried it in an editor yet but I will!

@dylwil3 dylwil3 force-pushed the requires-python branch 2 times, most recently from 583edc8 to 8aa8c56 Compare March 4, 2025 13:33
@MichaReiser MichaReiser changed the base branch from main to micha/ruff-0.10 March 10, 2025 14:24
@MichaReiser
Copy link
Member

@dylwil3 feel free to merge this into the ruff-0.10 feature branch once it's ready (I already changed the base)

@MichaReiser MichaReiser removed the do-not-merge Do not merge this pull request label Mar 10, 2025
Copy link
Member

@dhruvmanila dhruvmanila left a comment

Choose a reason for hiding this comment

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

The changes all look correct to me for the server settings.

Edit: Wait, I'm seeing some discrepancies in the editor context.

@dhruvmanila
Copy link
Member

dhruvmanila commented Mar 12, 2025

I'm currently testing out this in an editor context using some of the test cases mentioned in https://github.com/astral-sh/ruff/blob/8aa8c56cad2697c0e4c6a86aa997221e842dcfea/crates/ruff/tests/lint.rs

ruff/crates/ruff/tests/lint.rs

Lines 2088 to 2094 in 8aa8c56

/// ```
/// tmp
/// ├── pyproject.toml #<--- no `[tool.ruff]`
/// └── test.py
/// ```
#[test]
fn requires_python_no_tool() -> Result<()> {

For this test case, the target version in the editor is still giving me 3.9. As per the test case, it should be 3.11 ? The debug information gives me that the indexed configuration file is empty even though the project has a pyproject.toml file (without tool.ruff heading). I think this is because the server uses the settings_toml function that skips the pyproject.toml file without a tool.ruff heading:

let pyproject_toml = path.as_ref().join("pyproject.toml");
if pyproject_toml.is_file() && ruff_enabled(&pyproject_toml)? {
return Ok(Some(pyproject_toml));
}

I believe this isn't expected? Sorry for taking this on at the last moment before the 0.10 release.

ruff/crates/ruff/tests/lint.rs

Lines 2716 to 2724 in 8aa8c56

/// ```
/// tmp
/// ├── foo
/// │   ├── pyproject.toml #<-- no [tool.ruff], no `requires-python`
/// │   └── test.py
/// └── pyproject.toml #<-- no [tool.ruff], has `requires-python`
/// ```
#[test]
fn requires_python_pyproject_toml_above() -> Result<()> {

For this test case, I'm seeing the incorrect behavior where the target version is still 3.9.

ruff/crates/ruff/tests/lint.rs

Lines 2397 to 2404 in 8aa8c56

/// ```
/// tmp
/// ├── pyproject.toml #<-- no [tool.ruff]
/// ├── ruff.toml #<-- no `target-version`
/// └── test.py
/// ```
#[test]
fn requires_python_ruff_toml_no_target_fallback() -> Result<()> {

For this test case, I'm seeing the correct behavior where the target version is 3.11.

@MichaReiser
Copy link
Member

@dhruvmanila Thanks for catching the error! I was able to verify my attempted fix with the server in Helix, but I wasn't sure how to use my local build to test in VSCode. Is there documentation for how to do that somewhere?

It's relatively straightforward for as long as it doesn't require changes to the extension itself.

  • Install Ruff's VS code extension
  • Open the settings and set ruff.path to your debug ruff binary:

The following is my configuration

{
    "ruff.path": [
        "/Users/micha/astral/ruff/target/debug/ruff",
    ]
}

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Mar 12, 2025

Update: It looks like the case where there's no ruff.toml or [tool.ruff] in any pyproject.toml (but there is a requires-python) is still not working as expected for the server. I believe it's because the logic here for the "fallback to default in the absence of any config files" case was not reproduced in the server:

debug!("Using Ruff default settings");
let mut config = config_arguments.transform(Configuration::default());
if config.target_version.is_none() {
// If we have arrived here we know that there was no `pyproject.toml`
// containing a `[tool.ruff]` section found in an ancestral directory.
// (This is an implicit requirement in the function
// `pyproject::find_settings_toml`.)
// However, there may be a `pyproject.toml` with a `requires-python`
// specified, and that is what we look for in this step.
let fallback = find_fallback_target_version(
stdin_filename
.as_ref()

Looking into it now - sorry for all the last minute hullabaloo close to the release!

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Mar 12, 2025

I believe the server behavior is now fixed! 🤞 I wish there were a way to write tests for it, but I tried out the failing examples in VSCode and printed the configs and they work now.

Comment on lines 295 to 305
Ok(None) => {}
Ok(None) => {
let settings = RuffSettings::editor_only(editor_settings, &directory);
index.write().unwrap().insert(directory, Arc::new(settings));
}
Copy link
Member

Choose a reason for hiding this comment

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

If I'm understanding this change correctly, we'd now have a fallback settings for every directory in the project. I think this might create an unexpected behavior because for a file in a nested directory, it would use this fallback settings instead of the settings from the project root? Let me test this out.

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 tested out, I think this is an incorrect fix. Consider the following directory structure:

.
├── nested
│   └── foo
│       └── test.py
├── pyproject.toml
└── test.py

The nested/foo/test.py does not consider any settings from pyproject.toml, it's only the test.py file that will consider it.

Copy link
Member

Choose a reason for hiding this comment

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

It's not entirely clear to me why the change here is necessary. Shouldn't the changes above be sufficient?

Comment on lines 88 to 92
let mut config = Configuration::default();
if let Some(fallback_version) = find_fallback_target_version(root) {
config.target_version = Some(PythonVersion::from(fallback_version));
};

Copy link
Member

Choose a reason for hiding this comment

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

Does that mean that we'll pick up the requires-python constraint even if a user uses editor-only in their settings. I'd be a bit confused if editor-only loads any project settings. @dhruvmanila what's your take on this?

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 don't think we should be looking at any of the config files if the configuration preference is editorOnly unless the user has explicitly asks us using the ruff.configuration.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we won't have gotten this far in the code in that case, though, right? We exited earlier here:

if editor_settings.configuration_preference == ConfigurationPreference::EditorOnly {
tracing::debug!(
"Using editor-only settings for workspace: {} (skipped indexing)",
root.display()
);
return RuffSettingsIndex {
index: BTreeMap::default(),
fallback: Arc::new(RuffSettings::editor_only(editor_settings, root)),
};
}

Copy link
Member

Choose a reason for hiding this comment

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

That branch will be calling RuffSettings::editor_only to create a default configuration so we'll be getting there in this case.

Comment on lines 88 to 92
let settings = resolve_root_settings(
&pyproject,
config_arguments,
ConfigurationOrigin::UserSettings,
)?;
Copy link
Member

Choose a reason for hiding this comment

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

It seems inconsistent that we don't apply the fallback behavior if a user only has the user-level configuration. It means that we won't pick up the right python version if you have:

  • a user level ruff configuration
  • but use a pyproject.toml without a tool.ruff section at the project level

I think it would be good to apply the requires-python fallback in this case as well.

@MichaReiser
Copy link
Member

I pushed three new commits:

  1. This is more a quality of life improvement: The server now shows logs emitted with log::debug (instead of tracing::debug)
  2. Changes the behavior for user-level configurations: A pyproject.toml now always overrides the target-version from the user-level configuration. I'm not a 100% if this is the right change but it's what we do in Red Knot. However, Red Knot's model is slightly different so I'm not sure if this behavior makes sense for Ruff. Maybe it's best to revert it but i'm interested in feedback
  3. The main changes on the server side

@dhruvmanila and @dylwil3 could yo uboth take a look

@MichaReiser
Copy link
Member

MichaReiser commented Mar 13, 2025

@dylwil3 we should also update https://docs.astral.sh/ruff/configuration/#config-file-discovery or have a new section explaining the target-version discovery but we can do this in a separate PR

@dhruvmanila
Copy link
Member

2. Changes the behavior for user-level configurations: A pyproject.toml now always overrides the target-version from the user-level configuration. I'm not a 100% if this is the right change but it's what we do in Red Knot. However, Red Knot's model is slightly different so I'm not sure if this behavior makes sense for Ruff. Maybe it's best to revert it but i'm interested in feedback

IIUC, between a user-level config (~/.config/ruff.toml) and the pyproject.toml in the project, Ruff will prefer to use the requires-python in the project config if present. If not, it'll use the one in the user-level config if present.

Why are you not sure about the behavior in Ruff? I think preferring a project specific version makes sense intuitively, I'd be surprised to see that the requires-python isn't being picked up by Ruff even though it's defined in the project config.

@dylwil3
Copy link
Collaborator Author

dylwil3 commented Mar 13, 2025

I pushed three new commits:

Thank you, this looks awesome!

  1. This is more a quality of life improvement: The server now shows logs emitted with log::debug (instead of tracing::debug)

  1. Changes the behavior for user-level configurations: A pyproject.toml now always overrides the target-version from the user-level configuration. I'm not a 100% if this is the right change but it's what we do in Red Knot. However, Red Knot's model is slightly different so I'm not sure if this behavior makes sense for Ruff. Maybe it's best to revert it but i'm interested in feedback

This seems intuitive to me.

  1. The main changes on the server side

Tested this in VSCode and looks good! The behavior Dhruv pointed out before with a nested file no longer occurs, and the fallback matches the CLI behavior in the edge case where there is no [tool.ruff] or ruff.toml anywhere (so, in Dhruv's example, if you open VSCode from tmp you'll get a different target version than you would by opening VSCode from nested/foo).

I will work on some documentation!

@MichaReiser
Copy link
Member

Okay, so I think this PR is ready to land, assuming my changes look good.

@dhruvmanila
Copy link
Member

The code looks good, thank you for addressing the issues. I also did some quick testing (not the same ones as Dylan).

@dylwil3 dylwil3 merged commit 15c0535 into astral-sh:micha/ruff-0.10 Mar 13, 2025
20 of 21 checks passed
@dylwil3
Copy link
Collaborator Author

dylwil3 commented Mar 13, 2025

Thanks so much @dhruvmanila and @MichaReiser !!

@MichaReiser MichaReiser mentioned this pull request Mar 13, 2025
2 tasks
MichaReiser added a commit that referenced this pull request Mar 14, 2025
…ot found (#16721)

## Summary

Restores #16319 after it got
dropped from the 0.10 release branch :(

---------

Co-authored-by: dylwil3 <[email protected]>
@MichaReiser MichaReiser mentioned this pull request Mar 14, 2025
MichaReiser added a commit that referenced this pull request Mar 14, 2025
## Summary

Follow-up release for Ruff v0.10 that now includes the following two
changes that we intended to ship but slipped:

* Changes to how the Python version is inferred when a `target-version`
is not specified (#16319)
* `blanket-noqa` (`PGH004`): Also detect blanked file-level noqa
comments (and not just line level comments).

## Test plan

I verified that the binary built on this branch respects the
`requires-python` setting
([logs](https://www.diffchecker.com/qyJWYi6W/), left: v0.10, right:
v0.11)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking Breaking API change configuration Related to settings and configuration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

F821 not recognizing requires-python in pyproject.toml
3 participants