Repository: astral-sh/ruff
File: crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs (Rust)

Code before:
    arguments: &Arguments,
    func: &Expr,
) {
    if let ["airflow", "Dataset"]
    | ["airflow", "datasets", "Dataset"]
    | ["airflow", "sdk", "Asset"] = qualified_name.segments()
    {
        if let Some(second_arg) = arguments.find_positional(1) {
            if is_dict_expression(checker, second_arg) {
                let function_name = qualified_name.segments().last().unwrap_or(&"").to_string();
                checker.report_diagnostic(
                    Airflow3IncompatibleFunctionSignature {
                        function_name,
                        change_type: FunctionSignatureChangeType::PositionalArgumentChange {
                            message: "Use keyword argument `extra` instead of passing a dict as the second positional argument (e.g., `Asset(name=..., uri=..., extra=...)`)",
                        },
                    },
                    func.range(),
                );
            }
        }
    }
}

/// Check if an expression is a dictionary (either a literal or a `dict()` call).
fn is_dict_expression(checker: &Checker, expr: &Expr) -> bool {
    // Check for dict literal: {} or {"key": "value"}
    if expr.is_dict_expr() {
        return true;
    }

    // Check for dict() call
    if let Expr::Call(call) = expr {
        if checker
            .semantic()
            .resolve_builtin_symbol(&call.func)
            .is_some_and(|name| name == "dict")
        {
            return true;
        }
    }

    false
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum FunctionSignatureChangeType {
    /// Function signature changed to only accept keyword arguments.
    KeywordOnly { message: &'static str },
    /// Function signature changed to not accept certain positional arguments.
    PositionalArgumentChange { message: &'static str },

Review comment (suggestion):
There are a couple of other ways you could check this, if you want. The simplest is to check for `Expr::DictComp` (dict comprehension) as well as a dict literal. And then a more invasive check would be [`typing::is_dict`](https://github.com/astral-sh/ruff/blob/652783790aea7d8ad7dbd22b6960876f7c699928/crates/ruff_python_semantic/src/analyze/typing.rs#L1035), which will check if a variable binding can be resolved to a `dict` based on type annotations.

It's okay with me if you want to restrict the check to dict literals and `dict()` calls, though. We should just add tests for these other cases if you add additional checks.

Code after:
    arguments: &Arguments,
    func: &Expr,
) {
    if let ["airflow", "Dataset"]
    | ["airflow", "datasets", "Dataset"]
    | ["airflow", "sdk", "Asset"] = qualified_name.segments()
    {
        if let Some(second_arg) = arguments.find_positional(1) {
            if is_dict_expression(checker, second_arg) {
                let function_name = qualified_name.segments().last().unwrap_or(&"").to_string();
                checker.report_diagnostic(
                    Airflow3IncompatibleFunctionSignature {
                        function_name,
                        change_type: FunctionSignatureChangeType::PositionalArgumentChange {
                            message: "Use keyword argument `extra` instead of passing a dict as the second positional argument (e.g., `Asset(name=..., uri=..., extra=...)`)",
                        },
                    },
                    func.range(),
                );
            }
        }
    }
}

/// Check if an expression is a dictionary.
fn is_dict_expression(checker: &Checker, expr: &Expr) -> bool {
    match expr {
        Expr::Dict(_) => true,
        Expr::DictComp(_) => true,
        Expr::Call(call) => checker
            .semantic()
            .resolve_builtin_symbol(&call.func)
            .is_some_and(|name| name == "dict"),
        Expr::Name(name) => checker
            .semantic()
            .resolve_name(name)
            .map(|id| checker.semantic().binding(id))
            .is_some_and(|binding| typing::is_dict(binding, checker.semantic())),
        _ => false,
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum FunctionSignatureChangeType {
    /// Function signature changed to only accept keyword arguments.
    KeywordOnly { message: &'static str },
    /// Function signature changed to not accept certain positional arguments.
    PositionalArgumentChange { message: &'static str },
}