Skip to content

Commit f8d1d29

Browse files
Dev-iLclaude
andauthored
[airflow] Extract common utilities for use in new rules (#23630)
Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent b017564 commit f8d1d29

4 files changed

Lines changed: 121 additions & 21 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,37 @@ def test_inlet_events_dataset_subscript_ok(**context):
205205

206206
print(context["inlet_events"][Dataset("this://is-url")])
207207
print(context["inlet_events"][Asset("this://is-url")])
208+
209+
210+
# Same context checks with airflow.sdk import
211+
from airflow.sdk import task as sdk_task
212+
213+
214+
@sdk_task
215+
def sdk_access_deprecated_context_key(**context):
216+
execution_date = context["execution_date"]
217+
next_ds = context["next_ds"]
218+
219+
220+
@sdk_task
221+
def sdk_access_valid_context_key(**context):
222+
logical_date = context["logical_date"]
223+
224+
225+
# Test variant decorator forms like @task.branch and @task.short_circuit
226+
@task.branch
227+
def branch_task_with_deprecated_context(**context):
228+
execution_date = context["execution_date"]
229+
return "some_task"
230+
231+
232+
@task.short_circuit
233+
def short_circuit_task_with_deprecated_context(**context):
234+
next_ds = context["next_ds"]
235+
return True
236+
237+
238+
@task.branch()
239+
def branch_task_with_call_and_deprecated_context(**context):
240+
tomorrow_ds = context["tomorrow_ds"]
241+
return "some_task"

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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::helpers::map_callable;
67
use ruff_python_ast::name::{QualifiedName, QualifiedNameBuilder};
78
use ruff_python_ast::statement_visitor::StatementVisitor;
89
use ruff_python_ast::visitor::Visitor;
@@ -290,3 +291,38 @@ where
290291

291292
any_qualified_base_class(class_def, semantic, &is_base_class)
292293
}
294+
295+
/// Returns `true` if the current statement hierarchy has a function that's decorated with
296+
/// `@airflow.decorators.task` or `@airflow.sdk.task`.
297+
pub(crate) fn in_airflow_task_function(semantic: &SemanticModel) -> bool {
298+
semantic
299+
.current_statements()
300+
.find_map(|stmt| stmt.as_function_def_stmt())
301+
.is_some_and(|function_def| is_airflow_task(function_def, semantic))
302+
}
303+
304+
/// Returns `true` if the given function is decorated with `@airflow.decorators.task`
305+
/// (or `@airflow.sdk.task`), including variant forms like `@task.branch` and
306+
/// `@task.short_circuit`.
307+
pub(crate) fn is_airflow_task(function_def: &StmtFunctionDef, semantic: &SemanticModel) -> bool {
308+
function_def.decorator_list.iter().any(|decorator| {
309+
let expr = map_callable(&decorator.expression);
310+
311+
// Match `@task` and `@task()` directly.
312+
if semantic
313+
.resolve_qualified_name(expr)
314+
.is_some_and(|qn| matches!(qn.segments(), ["airflow", "decorators" | "sdk", "task"]))
315+
{
316+
return true;
317+
}
318+
319+
// Match `@task.<variant>` (e.g., `@task.branch`, `@task.short_circuit`).
320+
if let Expr::Attribute(ExprAttribute { value, .. }) = expr {
321+
return semantic.resolve_qualified_name(value).is_some_and(|qn| {
322+
matches!(qn.segments(), ["airflow", "decorators" | "sdk", "task"])
323+
});
324+
}
325+
326+
false
327+
})
328+
}

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

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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, is_method_in_subclass,
4+
in_airflow_task_function, is_airflow_builtin_or_provider, is_airflow_task,
5+
is_guarded_by_try_except, is_method_in_subclass,
56
};
67
use crate::{Edit, Fix, FixAvailability, Violation};
78
use ruff_macros::{ViolationMetadata, derive_message_formats};
@@ -1223,26 +1224,6 @@ fn is_airflow_auth_manager(segments: &[&str]) -> bool {
12231224
}
12241225
}
12251226

1226-
/// Returns `true` if the current statement hierarchy has a function that's decorated with
1227-
/// `@airflow.decorators.task`.
1228-
fn in_airflow_task_function(semantic: &SemanticModel) -> bool {
1229-
semantic
1230-
.current_statements()
1231-
.find_map(|stmt| stmt.as_function_def_stmt())
1232-
.is_some_and(|function_def| is_airflow_task(function_def, semantic))
1233-
}
1234-
1235-
/// Returns `true` if the given function is decorated with `@airflow.decorators.task`.
1236-
fn is_airflow_task(function_def: &StmtFunctionDef, semantic: &SemanticModel) -> bool {
1237-
function_def.decorator_list.iter().any(|decorator| {
1238-
semantic
1239-
.resolve_qualified_name(map_callable(&decorator.expression))
1240-
.is_some_and(|qualified_name| {
1241-
matches!(qualified_name.segments(), ["airflow", "decorators", "task"])
1242-
})
1243-
})
1244-
}
1245-
12461227
/// Check it's "execute" method inherits from Airflow base operator
12471228
///
12481229
/// For example:

crates/ruff_linter/src/rules/airflow/snapshots/ruff_linter__rules__airflow__tests__AIR301_AIR301_context.py.snap

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,3 +521,52 @@ AIR301 `inlet_events["<uri>"]` is removed in Airflow 3.0
521521
| ^^^^^^^^^^^^^^^
522522
|
523523
help: Accessing `inlet_events` via a string key is deprecated; use `context["inlet_events"][Asset(uri="this://is-url")]` instead of `context["inlet_events"]["this://is-url"]`.
524+
525+
AIR301 `execution_date` is removed in Airflow 3.0
526+
--> AIR301_context.py:216:30
527+
|
528+
214 | @sdk_task
529+
215 | def sdk_access_deprecated_context_key(**context):
530+
216 | execution_date = context["execution_date"]
531+
| ^^^^^^^^^^^^^^^^
532+
217 | next_ds = context["next_ds"]
533+
|
534+
535+
AIR301 `next_ds` is removed in Airflow 3.0
536+
--> AIR301_context.py:217:23
537+
|
538+
215 | def sdk_access_deprecated_context_key(**context):
539+
216 | execution_date = context["execution_date"]
540+
217 | next_ds = context["next_ds"]
541+
| ^^^^^^^^^
542+
|
543+
544+
AIR301 `execution_date` is removed in Airflow 3.0
545+
--> AIR301_context.py:228:30
546+
|
547+
226 | @task.branch
548+
227 | def branch_task_with_deprecated_context(**context):
549+
228 | execution_date = context["execution_date"]
550+
| ^^^^^^^^^^^^^^^^
551+
229 | return "some_task"
552+
|
553+
554+
AIR301 `next_ds` is removed in Airflow 3.0
555+
--> AIR301_context.py:234:23
556+
|
557+
232 | @task.short_circuit
558+
233 | def short_circuit_task_with_deprecated_context(**context):
559+
234 | next_ds = context["next_ds"]
560+
| ^^^^^^^^^
561+
235 | return True
562+
|
563+
564+
AIR301 `tomorrow_ds` is removed in Airflow 3.0
565+
--> AIR301_context.py:240:27
566+
|
567+
238 | @task.branch()
568+
239 | def branch_task_with_call_and_deprecated_context(**context):
569+
240 | tomorrow_ds = context["tomorrow_ds"]
570+
| ^^^^^^^^^^^^^
571+
241 | return "some_task"
572+
|

0 commit comments

Comments
 (0)