Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/ruff_linter/resources/test/fixtures/ruff/RUF010.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,19 @@ def ascii(arg):
)


# OK
# https://github.com/astral-sh/ruff/issues/16325
f"{str({})}"

f"{ascii({} | {})}"

import builtins

f"{builtins.repr(1)}"

f"{ascii(1)=}"

f"{ascii(lambda: 1)}"

f"{ascii(x := 2)}"

f"{str(object=3)}"
12 changes: 2 additions & 10 deletions crates/ruff_linter/src/cst/matchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use anyhow::{Result, bail};
use libcst_native::{
Arg, Attribute, Call, Comparison, CompoundStatement, Dict, Expression, FormattedString,
FormattedStringContent, FormattedStringExpression, FunctionDef, GeneratorExp, If, Import,
ImportAlias, ImportFrom, ImportNames, IndentedBlock, Lambda, ListComp, Module, Name,
SmallStatement, Statement, Suite, Tuple, With,
ImportAlias, ImportFrom, ImportNames, IndentedBlock, Lambda, ListComp, Module, SmallStatement,
Statement, Suite, Tuple, With,
};
use ruff_python_codegen::Stylist;

Expand Down Expand Up @@ -104,14 +104,6 @@ pub(crate) fn match_attribute<'a, 'b>(
}
}

pub(crate) fn match_name<'a, 'b>(expression: &'a Expression<'b>) -> Result<&'a Name<'b>> {
if let Expression::Name(name) = expression {
Ok(name)
} else {
bail!("Expected Expression::Name")
}
}

pub(crate) fn match_arg<'a, 'b>(call: &'a Call<'b>) -> Result<&'a Arg<'b>> {
if let Some(arg) = call.args.first() {
Ok(arg)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
use anyhow::{Result, bail};
use std::fmt::Display;

use anyhow::Result;

use libcst_native::{Expression, LeftParen, ParenthesizedNode, RightParen};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast::{self as ast, Arguments, Expr};
use ruff_python_codegen::Stylist;
use ruff_python_ast::{self as ast, Expr, InterpolatedStringElement};
use ruff_python_parser::TokenKind;
use ruff_text_size::Ranged;

use crate::Locator;
use crate::checkers::ast::Checker;
use crate::cst::helpers::space;
use crate::cst::matchers::{
match_call_mut, match_formatted_string, match_formatted_string_expression, match_name,
transform_expression,
match_call_mut, match_formatted_string, match_formatted_string_expression, transform_expression,
};
use crate::{AlwaysFixableViolation, Edit, Fix};
use crate::{Edit, Fix, FixAvailability, Violation};

/// ## What it does
/// Checks for uses of `str()`, `repr()`, and `ascii()` as explicit type
Expand Down Expand Up @@ -40,14 +42,16 @@ use crate::{AlwaysFixableViolation, Edit, Fix};
#[derive(ViolationMetadata)]
pub(crate) struct ExplicitFStringTypeConversion;

impl AlwaysFixableViolation for ExplicitFStringTypeConversion {
impl Violation for ExplicitFStringTypeConversion {
const FIX_AVAILABILITY: FixAvailability = FixAvailability::Sometimes;

#[derive_message_formats]
fn message(&self) -> String {
"Use explicit conversion flag".to_string()
}

fn fix_title(&self) -> String {
"Replace with conversion flag".to_string()
fn fix_title(&self) -> Option<String> {
Some("Replace with conversion flag".to_string())
}
}

Expand All @@ -68,84 +72,148 @@ pub(crate) fn explicit_f_string_type_conversion(checker: &Checker, f_string: &as
continue;
}

let Expr::Call(ast::ExprCall {
func,
arguments:
Arguments {
args,
keywords,
range: _,
node_index: _,
},
..
}) = expression.as_ref()
else {
let Expr::Call(call) = expression.as_ref() else {
continue;
};

// Can't be a conversion otherwise.
if !keywords.is_empty() {
let Some(conversion) = checker
.semantic()
.resolve_builtin_symbol(&call.func)
.and_then(Conversion::from_str)
else {
continue;
}
};
let arg = match conversion {
// Handles the cases: `f"{str(object=arg)}"` and `f"{str(arg)}"`
Conversion::Str if call.arguments.len() == 1 => {
let Some(arg) = call.arguments.find_argument_value("object", 0) else {
continue;
};
arg
}
Conversion::Str | Conversion::Repr | Conversion::Ascii => {
// Can't be a conversion otherwise.
if !call.arguments.keywords.is_empty() {
continue;
}

// Can't be a conversion otherwise.
let [arg] = &**args else {
continue;
// Can't be a conversion otherwise.
let [arg] = call.arguments.args.as_ref() else {
continue;
};
arg
}
};

// Avoid attempting to rewrite, e.g., `f"{str({})}"`; the curly braces are problematic.
if matches!(
arg,
Expr::Dict(_) | Expr::Set(_) | Expr::DictComp(_) | Expr::SetComp(_)
) {
continue;
// Suppress lint for starred expressions.
if arg.is_starred_expr() {
return;
}

if !checker
.semantic()
.resolve_builtin_symbol(func)
.is_some_and(|builtin| matches!(builtin, "str" | "repr" | "ascii"))
let mut diagnostic =
checker.report_diagnostic(ExplicitFStringTypeConversion, expression.range());

// Don't support fixing f-string with debug text.
if element
.as_interpolation()
.is_some_and(|interpolation| interpolation.debug_text.is_some())
{
continue;
return;
}

let mut diagnostic =
checker.report_diagnostic(ExplicitFStringTypeConversion, expression.range());
diagnostic.try_set_fix(|| {
convert_call_to_conversion_flag(f_string, index, checker.locator(), checker.stylist())
convert_call_to_conversion_flag(checker, conversion, f_string, index, element)
});
}
}

/// Generate a [`Fix`] to replace an explicit type conversion with a conversion flag.
fn convert_call_to_conversion_flag(
checker: &Checker,
conversion: Conversion,
f_string: &ast::FString,
index: usize,
locator: &Locator,
stylist: &Stylist,
element: &InterpolatedStringElement,
) -> Result<Fix> {
let source_code = locator.slice(f_string);
transform_expression(source_code, stylist, |mut expression| {
let source_code = checker.locator().slice(f_string);
transform_expression(source_code, checker.stylist(), |mut expression| {
let formatted_string = match_formatted_string(&mut expression)?;
// Replace the formatted call expression at `index` with a conversion flag.
let formatted_string_expression =
match_formatted_string_expression(&mut formatted_string.parts[index])?;
let call = match_call_mut(&mut formatted_string_expression.expression)?;
let name = match_name(&call.func)?;
match name.value {
"str" => {
formatted_string_expression.conversion = Some("s");
}
"repr" => {
formatted_string_expression.conversion = Some("r");
}
"ascii" => {
formatted_string_expression.conversion = Some("a");
}
_ => bail!("Unexpected function call: `{:?}`", name.value),

formatted_string_expression.conversion = Some(conversion.as_str());

if contains_brace(checker, element) {
formatted_string_expression.whitespace_before_expression = space();
}
formatted_string_expression.expression = call.args[0].value.clone();

formatted_string_expression.expression = if needs_paren(&call.args[0].value) {
call.args[0]
.value
.clone()
.with_parens(LeftParen::default(), RightParen::default())
} else {
call.args[0].value.clone()
};

Ok(expression)
})
.map(|output| Fix::safe_edit(Edit::range_replacement(output, f_string.range())))
}

fn contains_brace(checker: &Checker, element: &InterpolatedStringElement) -> bool {
let Some(interpolation) = element.as_interpolation() else {
return false;
};
let Some(call) = interpolation.expression.as_call_expr() else {
return false;
};

checker
.tokens()
.after(call.arguments.start())
.iter()
// Skip the trivia tokens and the `(` from the arguments
.find(|token| !token.kind().is_trivia() && token.kind() != TokenKind::Lpar)
.is_some_and(|token| matches!(token.kind(), TokenKind::Lbrace))
}

fn needs_paren(expr: &Expression) -> bool {
matches!(expr, Expression::Lambda(_) | Expression::NamedExpr(_))
}
Copy link
Member

Choose a reason for hiding this comment

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

We should add tests demonstrating this behavior. I think this is also another place where we could use OperatorPrecedence instead of listing all expressions where any precedence lower or equal to lambda require parenthesizing.

Copy link
Contributor Author

@LaBatata101 LaBatata101 Jun 25, 2025

Choose a reason for hiding this comment

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

We should add tests demonstrating this behavior.

We already have it, here:

f"{ascii(lambda: 1)}"
f"{ascii(x := 2)}"

Copy link
Member

Choose a reason for hiding this comment

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

It might then be unnecessary? Because I don't see any failing tests if I change the code to:

        formatted_string_expression.expression =
        // if needs_paren(OperatorPrecedence::from_expr(arg))
        // {
        //     call.args[0]
        //         .value
        //         .clone()
        //         .with_parens(LeftParen::default(), RightParen::default())
        // } else {
            call.args[0].value.clone();
        // };

Copy link
Contributor Author

@LaBatata101 LaBatata101 Jun 26, 2025

Choose a reason for hiding this comment

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

Oh, the diagnostic was not triggering for those cases in the test file because the ascii binding is being shadowed. I didn't notice that, thanks for pointing that out! Should be fixed now.


/// Represents the three built-in Python conversion functions that can be replaced
/// with f-string conversion flags.
#[derive(Copy, Clone)]
enum Conversion {
Ascii,
Str,
Repr,
}

impl Conversion {
fn from_str(value: &str) -> Option<Self> {
Some(match value {
"ascii" => Self::Ascii,
"str" => Self::Str,
"repr" => Self::Repr,
_ => return None,
})
}

fn as_str(self) -> &'static str {
match self {
Conversion::Ascii => "a",
Conversion::Str => "s",
Conversion::Repr => "r",
}
}
}

impl Display for Conversion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,61 @@ RUF010.py:35:20: RUF010 [*] Use explicit conversion flag
35 |+ f" that flows {obj!r} of type {type(obj)}.{additional_message}" # RUF010
36 36 | )
37 37 |
38 38 |
38 38 |

RUF010.py:40:4: RUF010 [*] Use explicit conversion flag
|
39 | # https://github.com/astral-sh/ruff/issues/16325
40 | f"{str({})}"
| ^^^^^^^ RUF010
41 |
42 | f"{ascii({} | {})}"
|
= help: Replace with conversion flag

ℹ Safe fix
37 37 |
38 38 |
39 39 | # https://github.com/astral-sh/ruff/issues/16325
40 |-f"{str({})}"
40 |+f"{ {}!s}"
41 41 |
42 42 | f"{ascii({} | {})}"
43 43 |

RUF010.py:46:4: RUF010 [*] Use explicit conversion flag
|
44 | import builtins
45 |
46 | f"{builtins.repr(1)}"
| ^^^^^^^^^^^^^^^^ RUF010
47 |
48 | f"{ascii(1)=}"
|
= help: Replace with conversion flag

ℹ Safe fix
43 43 |
44 44 | import builtins
45 45 |
46 |-f"{builtins.repr(1)}"
46 |+f"{1!r}"
47 47 |
48 48 | f"{ascii(1)=}"
49 49 |

RUF010.py:54:4: RUF010 [*] Use explicit conversion flag
|
52 | f"{ascii(x := 2)}"
53 |
54 | f"{str(object=3)}"
| ^^^^^^^^^^^^^ RUF010
|
= help: Replace with conversion flag

ℹ Safe fix
51 51 |
52 52 | f"{ascii(x := 2)}"
53 53 |
54 |-f"{str(object=3)}"
54 |+f"{3!s}"
Loading