[flake8-use-pathlib] Mark fixes unsafe for return type changes (PTH104, PTH105, PTH109, PTH115)#21440
Conversation
|
| code | total | + violation | - violation | + fix | - fix |
|---|---|---|---|---|---|
| PTH109 | 32 | 0 | 0 | 0 | 32 |
| PTH115 | 6 | 0 | 0 | 0 | 6 |
ntBre
left a comment
There was a problem hiding this comment.
I made a couple of suggestions for simplification, but we also need to verify that this change is only applied to the correct rules. I flagged a few cases inline where rules not mentioned in #21431 were changed unnecessarily, but I didn't check them exhaustively.
| let applicability = if checker.comment_ranges().intersects(range) { | ||
| Applicability::Unsafe | ||
| } else { | ||
| } else if is_top_level_expression_call(checker, call) { | ||
| // Safe when the call is a top-level expression (return value not used) | ||
| Applicability::Safe | ||
| } else { | ||
| // Unsafe because the return type changes (str/bytes -> Path) | ||
| Applicability::Unsafe | ||
| }; |
There was a problem hiding this comment.
I think I would slightly prefer something like:
| let applicability = if checker.comment_ranges().intersects(range) { | |
| Applicability::Unsafe | |
| } else { | |
| } else if is_top_level_expression_call(checker, call) { | |
| // Safe when the call is a top-level expression (return value not used) | |
| Applicability::Safe | |
| } else { | |
| // Unsafe because the return type changes (str/bytes -> Path) | |
| Applicability::Unsafe | |
| }; | |
| // Unsafe when the fix would delete comments or change a used return value | |
| let applicability = if checker.comment_ranges().intersects(range) | |
| || !is_top_level_expression_call(checker, call) { | |
| Applicability::Unsafe | |
| } else { | |
| Applicability::Safe | |
| }; |
| /// Additionally, the fix is marked as unsafe because `os.getcwd()` and `os.getcwdb()` return `str` or `bytes`, | ||
| /// while `Path.cwd()` returns a `Path` object. This change in return type can break code that uses the return value. | ||
| /// The fix is safe when the function call is a top-level expression in its statement (i.e., the return value is not used). |
There was a problem hiding this comment.
I think we could simplify this slightly to something like:
| /// Additionally, the fix is marked as unsafe because `os.getcwd()` and `os.getcwdb()` return `str` or `bytes`, | |
| /// while `Path.cwd()` returns a `Path` object. This change in return type can break code that uses the return value. | |
| /// The fix is safe when the function call is a top-level expression in its statement (i.e., the return value is not used). | |
| /// Additionally, the fix is marked as unsafe when the return value is used because the type changes | |
| /// from `str` or `bytes` to a `Path` object. |
I think something similar would work for the other documentation updates too.
| @@ -315,6 +317,7 @@ help: Replace with `Path(...).is_file()` | |||
| 22 | bbbbb = os.path.islink(p) | |||
| 23 | os.readlink(p) | |||
| 24 | os.stat(p) | |||
| note: This is an unsafe fix and may change runtime behavior | |||
There was a problem hiding this comment.
This doesn't look right. Should isfile be affected by these changes? I think both versions return a bool.
| 23 | os.readlink(p) | ||
| 24 | os.stat(p) | ||
| 25 | os.path.isabs(p) | ||
| note: This is an unsafe fix and may change runtime behavior |
There was a problem hiding this comment.
I think this one also shouldn't change.
| 19 | bb = foo_p.expanduser(p) | ||
| 20 | bbb = foo_p.isdir(p) | ||
| 21 | bbbb = foo_p.isfile(p) | ||
| note: This is an unsafe fix and may change runtime behavior |
There was a problem hiding this comment.
Another one that shouldn't change, from what I can tell.
| if let Stmt::Expr(ast::StmtExpr { | ||
| value: child, | ||
| range: _, | ||
| node_index: _, | ||
| }) = checker.semantic().current_statement() | ||
| { | ||
| // Check if the call is the same expression as the statement's value | ||
| // We compare by checking if the call's range is contained within the child's range | ||
| // and if they're the same expression node | ||
| if let Expr::Call(call_expr) = child.as_ref() { | ||
| return call_expr.range() == call.range(); | ||
| } | ||
| } | ||
| false |
There was a problem hiding this comment.
Assuming that call is the SemanticModel::current_expression, I think this function could just be:
| if let Stmt::Expr(ast::StmtExpr { | |
| value: child, | |
| range: _, | |
| node_index: _, | |
| }) = checker.semantic().current_statement() | |
| { | |
| // Check if the call is the same expression as the statement's value | |
| // We compare by checking if the call's range is contained within the child's range | |
| // and if they're the same expression node | |
| if let Expr::Call(call_expr) = child.as_ref() { | |
| return call_expr.range() == call.range(); | |
| } | |
| } | |
| false | |
| checker.semantic().current_expression_parent().is_none() |
|
Thanks for the feedback! I had thought that the same concepts applied to a few other rules, but it appears not after taking a closer look. |
ntBre
left a comment
There was a problem hiding this comment.
Thanks! This is looking better, but I had a few more suggestions.
I tried to flag all of the places the docs should say "str or bytes," but I started using typing.AnyStr as a shorthand since that's what ty shows. It's technically a type variable that depends on the input, but I think it's fine just to say "str or bytes" in each of those cases.
| let determined_applicability = if checker.comment_ranges().intersects(range) { | ||
| Applicability::Unsafe | ||
| } else if applicability.is_none() { | ||
| // When applicability is None, we need to determine it based on return type changes | ||
| if is_top_level_expression_call(checker, call) { | ||
| // Safe when the call is a top-level expression (return value not used) | ||
| Applicability::Safe | ||
| } else { | ||
| // Unsafe because the return type changes (str/bytes -> Path or None -> Path) | ||
| Applicability::Unsafe | ||
| } |
There was a problem hiding this comment.
I don't think we should do this check here. I checked all of the references for check_os_pathlib_single_arg_calls, and it's only used by PTH115, of the rules we're trying to fix in this PR.
I think we should just do the is_top_level_expression_call check in PTH115 itself.
| } else if is_top_level_expression_call(checker, call) { | ||
| // Safe when the call is a top-level expression (return value not used) | ||
| Applicability::Safe | ||
| } else { | ||
| // Unsafe because the return type changes (None -> Path) | ||
| Applicability::Unsafe |
There was a problem hiding this comment.
As above, this helper also affects rules outside of PTH{104,105,109,115}. We should narrow the check to the affected rules and pass that applicability into the helper.
There was a problem hiding this comment.
Just a note on this - to have applicability passed through the helper, I had to add #[allow(clippy::too_many_arguments)].
Let me know if that's fine or if you'd rather have it refactored.
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_dirname.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_abspath.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_expanduser.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_isfile.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_islink.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_path_exists.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_readlink.rs
Outdated
Show resolved
Hide resolved
crates/ruff_linter/src/rules/flake8_use_pathlib/rules/os_replace.rs
Outdated
Show resolved
Hide resolved
ntBre
left a comment
There was a problem hiding this comment.
Thanks! I just fixed one more round of tiny nits. Once the ecosystem check runs, I'll merge.
* origin/main: (67 commits) Move `Token`, `TokenKind` and `Tokens` to `ruff-python-ast` (#21760) [ty] Don't confuse multiple occurrences of `typing.Self` when binding bound methods (#21754) Use our org-wide Renovate preset (#21759) Delete `my-script.py` (#21751) [ty] Move `all_members`, and related types/routines, out of `ide_support.rs` (#21695) [ty] Fix find-references for import aliases (#21736) [ty] add tests for workspaces (#21741) [ty] Stop testing the (brittle) constraint set display implementation (#21743) [ty] Use generator over list comprehension to avoid cast (#21748) [ty] Add a diagnostic for prohibited `NamedTuple` attribute overrides (#21717) [ty] Fix subtyping with `type[T]` and unions (#21740) Use `npm ci --ignore-scripts` everywhere (#21742) [`flake8-simplify`] Fix truthiness assumption for non-iterable arguments in tuple/list/set calls (`SIM222`, `SIM223`) (#21479) [`flake8-use-pathlib`] Mark fixes unsafe for return type changes (`PTH104`, `PTH105`, `PTH109`, `PTH115`) (#21440) [ty] Fix auto-import code action to handle pre-existing import Enable PEP 740 attestations when publishing to PyPI (#21735) [ty] Fix find references for type defined in stub (#21732) Use OIDC instead of codspeed token (#21719) [ty] Exclude `typing_extensions` from completions unless it's really available [ty] Fix false positives for `class F(Generic[*Ts]): ...` (#21723) ...
Summary
Marks fixes as unsafe when they change return types (
None→Path,str/bytes→Path,str→Path), except when the call is a top-level expression.Fixes #21431.
Problem
Fixes for
os.rename,os.replace,os.getcwd/os.getcwdb, andos.readlinkwere marked safe despite changing return types, which can break code that uses the return value.Approach
Added
is_top_level_expression_callhelper to detect when a call is a top-level expression (return value unused). Updatedcheck_os_pathlib_two_arg_callsandcheck_os_pathlib_single_arg_callsto mark fixes as unsafe unless the call is a top-level expression. Updated PTH109 to use the helper for applicability determination.Test Plan
Updated snapshots for
preview_full_name.py,preview_import_as.py,preview_import_from.py, andpreview_import_from_as.pyto reflect unsafe markers.