Skip to content

Commit c2ffad5

Browse files
authored
[airflow] Third positional parameter not named ti_key should be flagged for BaseOperatorLink.get_link (AIR303) (#22828)
<!-- Thank you for contributing to Ruff/ty! To help us out with reviewing, please consider the following: - Does this pull request include a summary of the change? (See below.) - Does this pull request include a descriptive title? (Please prefix with `[ty]` for ty pull requests.) - Does this pull request include references to any relevant issues? --> ## Summary Context: apache/airflow#41641 apache/airflow#46415 >In the old signature, `dttm` is used by Airflow as a positional argument, so the argument name in the function declaration may not be `dttm`. In the new signature, however, the argument name must be `ti_key` (although it may be non-keyword-only; self, operator, ti_key should be considered new signature). So the deprecated signature detection rule should be exactly three positional arguments, the third one not named `ti_key`. Old Signature ```python class MyOperatorLink(BaseOperatorLink): def get_link(self, operator, dttm): ... ``` New Signature ```python class MyOperatorLink(BaseOperatorLink): def get_link(self, operator, *, ti_key): ... ``` This PR extends AIR303 to detect deprecated `get_link` method signatures in `BaseOperatorLink` subclasses, which is a method definition. `BaseOperatorLink` is a base class can be extended by the user, and the `get_link` method is called by Airflow. As the way Airflow internally call this method change, we need to flag the user's implementation if the method definition is not match with the new signature (@Lee-W , please correct me if I understand it wrong, whenever you have time, thanks). The rule detects deprecated signatures where: 1. The class inherits from `BaseOperatorLink` (from `airflow.models` or `airflow.sdk`) 2. The method is named `get_link` 3. There are exactly 3 positional parameters 4. The 3rd parameter is not named as `ti_key` <img width="584" height="342" alt="Screenshot from 2026-01-23 22-25-34" src="https://github.com/user-attachments/assets/3628b15f-0b87-4de9-bc24-9c84ea88d583" /> ## Test Plan The test cases are added to `AIR303.py` and the test snapshot is updated.
1 parent 1dc2993 commit c2ffad5

6 files changed

Lines changed: 300 additions & 50 deletions

File tree

crates/ruff_linter/resources/test/fixtures/airflow/AIR303.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,113 @@
7272
Asset("asset1")
7373
Asset("asset1", extra={"key": "value"})
7474
Asset(uri="asset1", extra={"key": "value"})
75+
76+
77+
from airflow.models.baseoperatorlink import BaseOperatorLink
78+
79+
# 3 positional args, 3rd NOT named 'ti_key'
80+
class DeprecatedOperatorLink1(BaseOperatorLink):
81+
def get_link(self, operator, dttm):
82+
pass
83+
84+
# keyword-only ti_key
85+
class ValidOperatorLink1(BaseOperatorLink):
86+
def get_link(self, operator, *, ti_key):
87+
pass
88+
89+
# positional ti_key with correct name
90+
class ValidOperatorLink2(BaseOperatorLink):
91+
def get_link(self, operator, ti_key):
92+
pass
93+
94+
# Multiple keyword-only args with ti_key
95+
class ValidOperatorLink3(BaseOperatorLink):
96+
def get_link(self, operator, *, ti_key, extra=None):
97+
pass
98+
99+
# *args with ti_key keyword-only
100+
class ValidOperatorLink4(BaseOperatorLink):
101+
def get_link(self, operator, *args, ti_key):
102+
pass
103+
104+
# **kwargs alongside ti_key
105+
class ValidOperatorLink5(BaseOperatorLink):
106+
def get_link(self, operator, *, ti_key, **kwargs):
107+
pass
108+
109+
# not exactly 3 positional args
110+
class InvalidOperatorLink1(BaseOperatorLink):
111+
def get_link(self, operator):
112+
pass
113+
114+
class InvalidOperatorLink2(BaseOperatorLink):
115+
def get_link(self, operator, ti_key, extra):
116+
pass
117+
118+
class InvalidOperatorLink3(BaseOperatorLink):
119+
def get_link(self, operator, *, something):
120+
pass
121+
122+
# Not a subclass of BaseOperatorLink
123+
class NotOperatorLinkSubclass:
124+
def get_link(self, operator, dttm):
125+
pass
126+
127+
# Not a class method
128+
def get_link(operator, dttm):
129+
pass
130+
131+
132+
from airflow.sdk import BaseOperatorLink
133+
134+
# 3 positional args, 3rd NOT named 'ti_key'
135+
class DeprecatedOperatorLink2(BaseOperatorLink):
136+
def get_link(self, operator, dttm):
137+
pass
138+
139+
# keyword-only ti_key
140+
class ValidOperatorLink6(BaseOperatorLink):
141+
def get_link(self, operator, *, ti_key):
142+
pass
143+
144+
# positional ti_key with correct name
145+
class ValidOperatorLink7(BaseOperatorLink):
146+
def get_link(self, operator, ti_key):
147+
pass
148+
149+
# Multiple keyword-only args with ti_key
150+
class ValidOperatorLink8(BaseOperatorLink):
151+
def get_link(self, operator, *, ti_key, extra=None):
152+
pass
153+
154+
# *args with ti_key keyword-only
155+
class ValidOperatorLink9(BaseOperatorLink):
156+
def get_link(self, operator, *args, ti_key):
157+
pass
158+
159+
# **kwargs alongside ti_key
160+
class ValidOperatorLink10(BaseOperatorLink):
161+
def get_link(self, operator, *, ti_key, **kwargs):
162+
pass
163+
164+
# not exactly 3 positional args
165+
class InvalidOperatorLink4(BaseOperatorLink):
166+
def get_link(self, operator):
167+
pass
168+
169+
class InvalidOperatorLink5(BaseOperatorLink):
170+
def get_link(self, operator, ti_key, extra):
171+
pass
172+
173+
class InvalidOperatorLink6(BaseOperatorLink):
174+
def get_link(self, operator, *, something):
175+
pass
176+
177+
# Not a subclass of BaseOperatorLink
178+
class NotOperatorLinkSubclass2:
179+
def get_link(self, operator, dttm):
180+
pass
181+
182+
# Not a class method
183+
def get_link(operator, dttm):
184+
pass

crates/ruff_linter/src/checkers/ast/analyze/statement.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
342342
if checker.is_rule_enabled(Rule::Airflow3Removal) {
343343
airflow::rules::airflow_3_removal_function_def(checker, function_def);
344344
}
345+
if checker.is_rule_enabled(Rule::Airflow3IncompatibleFunctionSignature) {
346+
airflow::rules::airflow_3_incompatible_method_signature_def(checker, function_def);
347+
}
345348
if checker.is_rule_enabled(Rule::NonPEP695GenericFunction) {
346349
pyupgrade::rules::non_pep695_generic_function(checker, function_def);
347350
}

crates/ruff_linter/src/rules/airflow/helpers.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ use crate::fix::edits::remove_unused_imports;
33
use crate::importer::ImportRequest;
44
use crate::rules::numpy::helpers::{AttributeSearcher, ImportSearcher};
55
use ruff_diagnostics::{Edit, Fix};
6-
use ruff_python_ast::name::QualifiedNameBuilder;
6+
use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder};
77
use ruff_python_ast::statement_visitor::StatementVisitor;
88
use ruff_python_ast::visitor::Visitor;
9-
use ruff_python_ast::{Expr, ExprAttribute, ExprName, StmtTry};
9+
use ruff_python_ast::{Expr, ExprAttribute, ExprName, StmtFunctionDef, StmtTry};
1010
use ruff_python_semantic::Exceptions;
11+
use ruff_python_semantic::ScopeKind;
1112
use ruff_python_semantic::SemanticModel;
13+
use ruff_python_semantic::analyze::class::any_qualified_base_class;
1214
use ruff_python_semantic::{MemberNameImport, NameImport};
1315
use ruff_text_size::Ranged;
1416
use ruff_text_size::TextRange;
@@ -68,6 +70,12 @@ pub(crate) enum ProviderReplacement {
6870
},
6971
}
7072

73+
#[derive(Clone, Debug, Eq, PartialEq)]
74+
pub(crate) enum FunctionSignatureChange {
75+
/// Carries a message describing the function signature change.
76+
Message(&'static str),
77+
}
78+
7179
pub(crate) fn is_guarded_by_try_except(
7280
expr: &Expr,
7381
module: &str,
@@ -260,3 +268,25 @@ pub(crate) fn generate_remove_and_runtime_import_edit(
260268

261269
Some(Fix::unsafe_edits(remove_edit, [import_edit]))
262270
}
271+
272+
/// This is a helper function to check if the given function definition is a method
273+
/// that inherits from a base class.
274+
pub(crate) fn is_method_in_subclass<F>(
275+
function_def: &StmtFunctionDef,
276+
semantic: &SemanticModel,
277+
method_name: &str,
278+
is_base_class: F,
279+
) -> bool
280+
where
281+
F: Fn(QualifiedName) -> bool,
282+
{
283+
if function_def.name.as_str() != method_name {
284+
return false;
285+
}
286+
287+
let ScopeKind::Class(class_def) = semantic.current_scope().kind else {
288+
return false;
289+
};
290+
291+
any_qualified_base_class(class_def, semantic, &is_base_class)
292+
}

crates/ruff_linter/src/rules/airflow/rules/function_signature_change_in_3.rs

Lines changed: 68 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::checkers::ast::Checker;
2+
use crate::rules::airflow::helpers::{FunctionSignatureChange, is_method_in_subclass};
23
use crate::{FixAvailability, Violation};
34
use ruff_macros::{ViolationMetadata, derive_message_formats};
45
use ruff_python_ast::name::QualifiedName;
5-
use ruff_python_ast::{Arguments, Expr, ExprAttribute, ExprCall, Identifier};
6+
use ruff_python_ast::{Arguments, Expr, ExprAttribute, ExprCall, Identifier, StmtFunctionDef};
67
use ruff_python_semantic::Modules;
78
use ruff_python_semantic::analyze::typing;
89
use ruff_text_size::Ranged;
@@ -38,34 +39,22 @@ use ruff_text_size::Ranged;
3839
#[violation_metadata(preview_since = "0.14.11")]
3940
pub(crate) struct Airflow3IncompatibleFunctionSignature {
4041
function_name: String,
41-
change_type: FunctionSignatureChangeType,
42+
change: FunctionSignatureChange,
4243
}
4344

4445
impl Violation for Airflow3IncompatibleFunctionSignature {
4546
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
4647

4748
#[derive_message_formats]
4849
fn message(&self) -> String {
49-
let Airflow3IncompatibleFunctionSignature {
50-
function_name,
51-
change_type,
52-
} = self;
53-
match change_type {
54-
FunctionSignatureChangeType::KeywordOnly { .. }
55-
| FunctionSignatureChangeType::PositionalArgumentChange { .. } => {
56-
format!("`{function_name}` signature is changed in Airflow 3.0")
57-
}
58-
}
50+
let Airflow3IncompatibleFunctionSignature { function_name, .. } = self;
51+
format!("`{function_name}` signature is changed in Airflow 3.0")
5952
}
6053

6154
fn fix_title(&self) -> Option<String> {
62-
let Airflow3IncompatibleFunctionSignature { change_type, .. } = self;
63-
match change_type {
64-
FunctionSignatureChangeType::KeywordOnly { message }
65-
| FunctionSignatureChangeType::PositionalArgumentChange { message } => {
66-
Some(message.to_string())
67-
}
68-
}
55+
let Airflow3IncompatibleFunctionSignature { change, .. } = self;
56+
let FunctionSignatureChange::Message(message) = change;
57+
Some((*message).to_string())
6958
}
7059
}
7160

@@ -106,6 +95,60 @@ pub(crate) fn airflow_3_incompatible_function_signature(checker: &Checker, expr:
10695
check_constructor_arguments(checker, &qualified_name, arguments, func);
10796
}
10897

98+
/// AIR303
99+
pub(crate) fn airflow_3_incompatible_method_signature_def(
100+
checker: &Checker,
101+
function_def: &StmtFunctionDef,
102+
) {
103+
if !checker.semantic().seen_module(Modules::AIRFLOW) {
104+
return;
105+
}
106+
107+
// Check for deprecated get_link signature in BaseOperatorLink subclasses
108+
if is_method_in_subclass(
109+
function_def,
110+
checker.semantic(),
111+
"get_link",
112+
|qualified_name| {
113+
matches!(
114+
qualified_name.segments(),
115+
["airflow", "models" | "sdk", .., "BaseOperatorLink"]
116+
)
117+
},
118+
) {
119+
let parameters = &function_def.parameters;
120+
let positional_count = parameters.posonlyargs.len() + parameters.args.len();
121+
122+
let is_valid_signature = match positional_count {
123+
// check valid signature `def get_link(self, operator, *, ti_key)`
124+
2 => parameters
125+
.kwonlyargs
126+
.iter()
127+
.any(|p| p.name().as_str() == "ti_key"),
128+
// check valid signature `def get_link(self, operator, ti_key)`
129+
3 => parameters
130+
.posonlyargs
131+
.iter()
132+
.chain(parameters.args.iter())
133+
.nth(2)
134+
.is_some_and(|p| p.name().as_str() == "ti_key"),
135+
_ => false,
136+
};
137+
138+
if !is_valid_signature {
139+
checker.report_diagnostic(
140+
Airflow3IncompatibleFunctionSignature {
141+
function_name: "get_link".to_string(),
142+
change: FunctionSignatureChange::Message(
143+
"Use `def get_link(self, operator, *, ti_key)` or `def get_link(self, operator, ti_key)` as the method signature.",
144+
),
145+
},
146+
function_def.name.range(),
147+
);
148+
}
149+
}
150+
}
151+
109152
fn check_method_arguments(
110153
checker: &Checker,
111154
qualified_name: &QualifiedName,
@@ -120,9 +163,9 @@ fn check_method_arguments(
120163
checker.report_diagnostic(
121164
Airflow3IncompatibleFunctionSignature {
122165
function_name: attr.to_string(),
123-
change_type: FunctionSignatureChangeType::KeywordOnly {
124-
message: "Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)",
125-
},
166+
change: FunctionSignatureChange::Message(
167+
"Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)",
168+
),
126169
},
127170
attr.range(),
128171
);
@@ -146,9 +189,9 @@ fn check_constructor_arguments(
146189
checker.report_diagnostic(
147190
Airflow3IncompatibleFunctionSignature {
148191
function_name,
149-
change_type: FunctionSignatureChangeType::PositionalArgumentChange {
150-
message: "Use keyword argument `extra` instead of passing a dict as the second positional argument (e.g., `Asset(name=..., uri=..., extra=...)`)",
151-
},
192+
change: FunctionSignatureChange::Message(
193+
"Use keyword argument `extra` instead of passing a dict as the second positional argument (e.g., `Asset(name=..., uri=..., extra=...)`)",
194+
),
152195
},
153196
func.range(),
154197
);
@@ -174,11 +217,3 @@ fn is_dict_expression(checker: &Checker, expr: &Expr) -> bool {
174217
_ => false,
175218
}
176219
}
177-
178-
#[derive(Clone, Debug, Eq, PartialEq)]
179-
pub(crate) enum FunctionSignatureChangeType {
180-
/// Function signature changed to only accept keyword arguments.
181-
KeywordOnly { message: &'static str },
182-
/// Function signature changed to not accept certain positional arguments.
183-
PositionalArgumentChange { message: &'static str },
184-
}

crates/ruff_linter/src/rules/airflow/rules/removal_in_3.rs

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::checkers::ast::Checker;
22
use crate::rules::airflow::helpers::{
33
Replacement, generate_import_edit, generate_remove_and_runtime_import_edit,
4-
is_airflow_builtin_or_provider, is_guarded_by_try_except,
4+
is_airflow_builtin_or_provider, is_guarded_by_try_except, is_method_in_subclass,
55
};
66
use crate::{Edit, Fix, FixAvailability, Violation};
77
use ruff_macros::{ViolationMetadata, derive_message_formats};
@@ -1199,19 +1199,7 @@ fn is_execute_method_inherits_from_airflow_operator(
11991199
function_def: &StmtFunctionDef,
12001200
semantic: &SemanticModel,
12011201
) -> bool {
1202-
if function_def.name.as_str() != "execute" {
1203-
return false;
1204-
}
1205-
1206-
let ScopeKind::Class(class_def) = semantic.current_scope().kind else {
1207-
return false;
1208-
};
1209-
1210-
class_def.bases().iter().any(|class_base| {
1211-
semantic
1212-
.resolve_qualified_name(class_base)
1213-
.is_some_and(|qualified_name| {
1214-
matches!(qualified_name.segments(), ["airflow", .., "BaseOperator"])
1215-
})
1202+
is_method_in_subclass(function_def, semantic, "execute", |qualified_name| {
1203+
matches!(qualified_name.segments(), ["airflow", .., "BaseOperator"])
12161204
})
12171205
}

0 commit comments

Comments
 (0)