Skip to content

Commit d8f4d3e

Browse files
authored
[airflow] Passing positional argument into airflow.lineage.hook.HookLineageCollector.create_asset is not allowed (AIR303) (#22046)
## Summary This is a follow up PR to astral-sh/ruff#21096 The new code AIR303 is added for checking function signature change in Airflow 3.0. The new rule added to AIR303 will check if positional argument is passed into `airflow.lineage.hook.HookLineageCollector.create_asset`. Since this method is updated to accept only keywords argument, passing positional argument into it is not allowed, and will raise an error. The test is done by checking whether positional argument with 0 index can be found. ## Test Plan A new test file is added to the fixtures for the code AIR303. Snapshot test is updated accordingly. <img width="1444" height="513" alt="Screenshot from 2025-12-17 20-54-48" src="https://github.com/user-attachments/assets/bc235195-e986-4743-9bf7-bba65805fb87" /> <img width="981" height="433" alt="Screenshot from 2025-12-17 21-34-29" src="https://github.com/user-attachments/assets/492db71f-58f2-40ba-ad2f-f74852fa5a6b" />
1 parent 95a71b9 commit d8f4d3e

8 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from airflow.lineage.hook import HookLineageCollector
4+
5+
# airflow.lineage.hook
6+
hlc = HookLineageCollector()
7+
hlc.create_asset("there")
8+
hlc.create_asset("should", "be", "no", "posarg")
9+
hlc.create_asset(name="but", uri="kwargs are ok")
10+
hlc.create_asset()
11+
12+
HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
13+
HookLineageCollector().create_asset("there")
14+
HookLineageCollector().create_asset("should", "be", "no", "posarg")
15+
16+
args = ["uri_value"]
17+
hlc.create_asset(*args)
18+
HookLineageCollector().create_asset(*args)
19+
20+
# Literal unpacking
21+
hlc.create_asset(*["literal_uri"])
22+
HookLineageCollector().create_asset(*["literal_uri"])
23+
24+
# starred args with keyword args
25+
hlc.create_asset(*args, extra="value")
26+
HookLineageCollector().create_asset(*args, extra="value")
27+
28+
# Double-starred keyword arguments
29+
kwargs = {"uri": "value", "name": "test"}
30+
hlc.create_asset(**kwargs)
31+
HookLineageCollector().create_asset(**kwargs)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
12781278
if checker.is_rule_enabled(Rule::Airflow3SuggestedUpdate) {
12791279
airflow::rules::airflow_3_0_suggested_update_expr(checker, expr);
12801280
}
1281+
if checker.is_rule_enabled(Rule::Airflow3IncompatibleFunctionSignature) {
1282+
airflow::rules::airflow_3_incompatible_function_signature(checker, expr);
1283+
}
12811284
if checker.is_rule_enabled(Rule::UnnecessaryCastToInt) {
12821285
ruff::rules::unnecessary_cast_to_int(checker, call);
12831286
}

crates/ruff_linter/src/codes.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
11241124
(Airflow, "002") => rules::airflow::rules::AirflowDagNoScheduleArgument,
11251125
(Airflow, "301") => rules::airflow::rules::Airflow3Removal,
11261126
(Airflow, "302") => rules::airflow::rules::Airflow3MovedToProvider,
1127+
(Airflow, "303") => rules::airflow::rules::Airflow3IncompatibleFunctionSignature,
11271128
(Airflow, "311") => rules::airflow::rules::Airflow3SuggestedUpdate,
11281129
(Airflow, "312") => rules::airflow::rules::Airflow3SuggestedToMoveToProvider,
11291130

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ mod tests {
4747
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_zendesk.py"))]
4848
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_standard.py"))]
4949
#[test_case(Rule::Airflow3MovedToProvider, Path::new("AIR302_try.py"))]
50+
#[test_case(Rule::Airflow3IncompatibleFunctionSignature, Path::new("AIR303.py"))]
5051
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_args.py"))]
5152
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_names.py"))]
5253
#[test_case(Rule::Airflow3SuggestedUpdate, Path::new("AIR311_try.py"))]
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use crate::checkers::ast::Checker;
2+
use crate::{FixAvailability, Violation};
3+
use ruff_macros::{ViolationMetadata, derive_message_formats};
4+
use ruff_python_ast::name::QualifiedName;
5+
use ruff_python_ast::{Arguments, Expr, ExprAttribute, ExprCall, Identifier};
6+
use ruff_python_semantic::Modules;
7+
use ruff_python_semantic::analyze::typing;
8+
use ruff_text_size::Ranged;
9+
10+
/// ## What it does
11+
/// Checks for Airflow function calls that will raise a runtime error in Airflow 3.0
12+
/// due to function signature changes, such as functions that changed to accept only
13+
/// keyword arguments, parameter reordering, or parameter type changes.
14+
///
15+
/// ## Why is this bad?
16+
/// Airflow 3.0 introduces changes to function signatures. Code that
17+
/// worked in Airflow 2.x will raise a runtime error if not updated in Airflow
18+
/// 3.0.
19+
///
20+
/// ## Example
21+
/// ```python
22+
/// from airflow.lineage.hook import HookLineageCollector
23+
///
24+
/// collector = HookLineageCollector()
25+
/// # Passing positional arguments will raise a runtime error in Airflow 3.0
26+
/// collector.create_asset("s3://bucket/key")
27+
/// ```
28+
///
29+
/// Use instead:
30+
/// ```python
31+
/// from airflow.lineage.hook import HookLineageCollector
32+
///
33+
/// collector = HookLineageCollector()
34+
/// # Passing arguments as keyword arguments instead of positional arguments
35+
/// collector.create_asset(uri="s3://bucket/key")
36+
/// ```
37+
#[derive(ViolationMetadata)]
38+
#[violation_metadata(preview_since = "0.14.11")]
39+
pub(crate) struct Airflow3IncompatibleFunctionSignature {
40+
function_name: String,
41+
change_type: FunctionSignatureChangeType,
42+
}
43+
44+
impl Violation for Airflow3IncompatibleFunctionSignature {
45+
const FIX_AVAILABILITY: FixAvailability = FixAvailability::None;
46+
47+
#[derive_message_formats]
48+
fn message(&self) -> String {
49+
let Airflow3IncompatibleFunctionSignature {
50+
function_name,
51+
change_type,
52+
} = self;
53+
match change_type {
54+
FunctionSignatureChangeType::KeywordOnly { .. } => {
55+
format!("`{function_name}` signature is changed in Airflow 3.0")
56+
}
57+
}
58+
}
59+
60+
fn fix_title(&self) -> Option<String> {
61+
let Airflow3IncompatibleFunctionSignature { change_type, .. } = self;
62+
match change_type {
63+
FunctionSignatureChangeType::KeywordOnly { message } => Some(message.to_string()),
64+
}
65+
}
66+
}
67+
68+
/// AIR303
69+
pub(crate) fn airflow_3_incompatible_function_signature(checker: &Checker, expr: &Expr) {
70+
if !checker.semantic().seen_module(Modules::AIRFLOW) {
71+
return;
72+
}
73+
74+
let Expr::Call(ExprCall {
75+
func, arguments, ..
76+
}) = expr
77+
else {
78+
return;
79+
};
80+
81+
let Expr::Attribute(ExprAttribute { attr, value, .. }) = func.as_ref() else {
82+
return;
83+
};
84+
85+
// Resolve the qualified name: try variable assignments first, then fall back to direct
86+
// constructor calls.
87+
let qualified_name = typing::resolve_assignment(value, checker.semantic()).or_else(|| {
88+
value
89+
.as_call_expr()
90+
.and_then(|call| checker.semantic().resolve_qualified_name(&call.func))
91+
});
92+
93+
let Some(qualified_name) = qualified_name else {
94+
return;
95+
};
96+
97+
check_keyword_only_method(checker, &qualified_name, attr, arguments);
98+
}
99+
100+
fn check_keyword_only_method(
101+
checker: &Checker,
102+
qualified_name: &QualifiedName,
103+
attr: &Identifier,
104+
arguments: &Arguments,
105+
) {
106+
let has_positional_args =
107+
arguments.find_positional(0).is_some() || arguments.args.iter().any(Expr::is_starred_expr);
108+
109+
if let ["airflow", "lineage", "hook", "HookLineageCollector"] = qualified_name.segments() {
110+
if attr.as_str() == "create_asset" && has_positional_args {
111+
checker.report_diagnostic(
112+
Airflow3IncompatibleFunctionSignature {
113+
function_name: attr.to_string(),
114+
change_type: FunctionSignatureChangeType::KeywordOnly {
115+
message: "Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)",
116+
},
117+
},
118+
attr.range(),
119+
);
120+
}
121+
}
122+
}
123+
124+
#[derive(Clone, Debug, Eq, PartialEq)]
125+
pub(crate) enum FunctionSignatureChangeType {
126+
/// Function signature changed to only accept keyword arguments.
127+
KeywordOnly { message: &'static str },
128+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
pub(crate) use dag_schedule_argument::*;
2+
pub(crate) use function_signature_change_in_3::*;
23
pub(crate) use moved_to_provider_in_3::*;
34
pub(crate) use removal_in_3::*;
45
pub(crate) use suggested_to_move_to_provider_in_3::*;
56
pub(crate) use suggested_to_update_3_0::*;
67
pub(crate) use task_variable_name::*;
78

89
mod dag_schedule_argument;
10+
mod function_signature_change_in_3;
911
mod moved_to_provider_in_3;
1012
mod removal_in_3;
1113
mod suggested_to_move_to_provider_in_3;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
---
2+
source: crates/ruff_linter/src/rules/airflow/mod.rs
3+
---
4+
AIR303 `create_asset` signature is changed in Airflow 3.0
5+
--> AIR303.py:7:5
6+
|
7+
5 | # airflow.lineage.hook
8+
6 | hlc = HookLineageCollector()
9+
7 | hlc.create_asset("there")
10+
| ^^^^^^^^^^^^
11+
8 | hlc.create_asset("should", "be", "no", "posarg")
12+
9 | hlc.create_asset(name="but", uri="kwargs are ok")
13+
|
14+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
15+
16+
AIR303 `create_asset` signature is changed in Airflow 3.0
17+
--> AIR303.py:8:5
18+
|
19+
6 | hlc = HookLineageCollector()
20+
7 | hlc.create_asset("there")
21+
8 | hlc.create_asset("should", "be", "no", "posarg")
22+
| ^^^^^^^^^^^^
23+
9 | hlc.create_asset(name="but", uri="kwargs are ok")
24+
10 | hlc.create_asset()
25+
|
26+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
27+
28+
AIR303 `create_asset` signature is changed in Airflow 3.0
29+
--> AIR303.py:13:24
30+
|
31+
12 | HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
32+
13 | HookLineageCollector().create_asset("there")
33+
| ^^^^^^^^^^^^
34+
14 | HookLineageCollector().create_asset("should", "be", "no", "posarg")
35+
|
36+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
37+
38+
AIR303 `create_asset` signature is changed in Airflow 3.0
39+
--> AIR303.py:14:24
40+
|
41+
12 | HookLineageCollector().create_asset(name="but", uri="kwargs are ok")
42+
13 | HookLineageCollector().create_asset("there")
43+
14 | HookLineageCollector().create_asset("should", "be", "no", "posarg")
44+
| ^^^^^^^^^^^^
45+
15 |
46+
16 | args = ["uri_value"]
47+
|
48+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
49+
50+
AIR303 `create_asset` signature is changed in Airflow 3.0
51+
--> AIR303.py:17:5
52+
|
53+
16 | args = ["uri_value"]
54+
17 | hlc.create_asset(*args)
55+
| ^^^^^^^^^^^^
56+
18 | HookLineageCollector().create_asset(*args)
57+
|
58+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
59+
60+
AIR303 `create_asset` signature is changed in Airflow 3.0
61+
--> AIR303.py:18:24
62+
|
63+
16 | args = ["uri_value"]
64+
17 | hlc.create_asset(*args)
65+
18 | HookLineageCollector().create_asset(*args)
66+
| ^^^^^^^^^^^^
67+
19 |
68+
20 | # Literal unpacking
69+
|
70+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
71+
72+
AIR303 `create_asset` signature is changed in Airflow 3.0
73+
--> AIR303.py:21:5
74+
|
75+
20 | # Literal unpacking
76+
21 | hlc.create_asset(*["literal_uri"])
77+
| ^^^^^^^^^^^^
78+
22 | HookLineageCollector().create_asset(*["literal_uri"])
79+
|
80+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
81+
82+
AIR303 `create_asset` signature is changed in Airflow 3.0
83+
--> AIR303.py:22:24
84+
|
85+
20 | # Literal unpacking
86+
21 | hlc.create_asset(*["literal_uri"])
87+
22 | HookLineageCollector().create_asset(*["literal_uri"])
88+
| ^^^^^^^^^^^^
89+
23 |
90+
24 | # starred args with keyword args
91+
|
92+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
93+
94+
AIR303 `create_asset` signature is changed in Airflow 3.0
95+
--> AIR303.py:25:5
96+
|
97+
24 | # starred args with keyword args
98+
25 | hlc.create_asset(*args, extra="value")
99+
| ^^^^^^^^^^^^
100+
26 | HookLineageCollector().create_asset(*args, extra="value")
101+
|
102+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)
103+
104+
AIR303 `create_asset` signature is changed in Airflow 3.0
105+
--> AIR303.py:26:24
106+
|
107+
24 | # starred args with keyword args
108+
25 | hlc.create_asset(*args, extra="value")
109+
26 | HookLineageCollector().create_asset(*args, extra="value")
110+
| ^^^^^^^^^^^^
111+
27 |
112+
28 | # Double-starred keyword arguments
113+
|
114+
help: Pass positional arguments as keyword arguments (e.g., `create_asset(uri=...)`)

ruff.schema.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)