## Summary
Adds a new rule `AIR004` that detects `@task.branch` decorated functions
that could be replaced with `@task.short_circuit`.
In Airflow,
[`@task.branch`](https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dags.html#branching)
selects which downstream tasks to run by returning a list of task IDs
(or an empty list to skip all). When the function has at least two
`return` statements and exactly one of them returns a non-empty list, it
is effectively acting as a boolean short-circuit (i.e. either run one
specific set of downstream tasks or skip them all). In that case,
[`@task.short_circuit`](https://www.astronomer.io/docs/learn/airflow-branch-operator#taskshort_circuit-shortcircuitoperator)
is a simpler and more readable alternative that returns `True`/`False`
instead.
```python
# Before (AIR004)
@task.branch
def my_task():
if condition:
return ["my_downstream_task"]
return []
# After
@task.short_circuit
def my_task():
return condition
```
### Implementation details
- Resolves the `@task.branch` decorator via the semantic model
(`airflow.decorators.task` + `.branch` attribute), handling both
`@task.branch` and `@task.branch()` call forms via `map_callable`.
- Uses `ReturnStatementVisitor` to collect all `return` statements
recursively (including those inside nested `if`/`else`/`for`/`while`
blocks).
- Flags the function when: `len(returns) >= 2` and exactly one return
has a non-empty list value.
### What it does NOT flag
- Functions with multiple non-empty list returns (genuine branching
logic).
- Functions with all-empty returns (no downstream tasks selected at
all).
- Functions with only a single return statement.
- Functions not decorated with `@task.branch`.
- Functions returning non-list values (strings, `None`, etc.).
## Test Plan
<!-- How was it tested? -->
Added snapshot tests in `AIR004.py` covering both violation and
non-violation cases:
- two returns with one non-empty list
- three returns with one non-empty list
- nested returns
- multiple non-empty returns
- all-empty returns
- single return
- undecorated functions
- `@task.short_circuit` decorated functions
## Summary
Fixes#24159
Adds a new rule RUF072 (`fstring-percent-format`) that flags any use of
the `%` operator on an f-string.
Mixing f-string interpolation with `%`-formatting is almost certainly a
mistake since both serve the same purpose. There's no valid use case for
using `%` on an f-string.
```python
# Flagged
f"{name}" % name
f"hello %s %s" % (1, 2)
f"value: {x}" % {"key": "value"}
# OK — plain string literals are handled by existing F50x rules
"hello %s" % name
"%s %s" % (1, 2)
```
## Test Plan
- Added test cases for f-strings with `%` on various RHS types
(variables, tuples, dicts, literals)
- Added test cases for plain string literals (not flagged — handled by
F50x)
- Verified existing ruff and pyflakes tests still pass
```shell
cargo test -p ruff_linter -- fstring_percent_format
```
Test file:
`crates/ruff_linter/src/rules/ruff/snapshots/ruff_linter__rules__ruff__tests__RUF072_RUF072.py.snap`
Test cases cover:
- F-string with `%` and variable RHS: `f"{banana}" % banana`
- F-string with `%` and tuple RHS: `f"hello %s %s" % (1, 2)`
- F-string with `%` and dict RHS: `f"value: {x}" % {"key": "value"}`
- F-string with `%` and literal RHS: `f"{x}" % 42`
- Plain string literals not flagged (handled by existing F50x rules)
Closes#19158
Implements the `useless_finally` rule (`RUF072`), which detects useless
`finally` blocks that only contain `pass` or `...`.
It handles two cases:
- `try/except/finally: pass` - the `finally` clause is removed, leaving
a valid `try/except`
- bare `try/finally: pass`: the entire `try/finally` is unwrapped, the
try body is dedented to replace the whole statement
Fix is skipped when comments are present in or around the `finally`
block.
It complements with existing rules like `RUF047` (`needless-else`) and
`SIM105` (`suppressible-exception`). It case of `SIM105` it also
unblocks this rule, as currently `SIM105` got skipped if `finally` has
any body at all (even just `pass`).
## Test Plan
- `RUF072.py` - main rule test with error cases and non-error.
- `useless_finally_and_needless_else` - test function, which checks how
`RUF047` and `RUF072` work together on the same `try` statement.
- `useless_finally_and_suppressible_exception` - test function, which
checks how `RUF072` and `SIM105` work together.
## Summary
Implements a new rule `AIR003` (`airflow-variable-get-outside-task`)
that flags `Variable.get()` calls outside of task execution context.
Per the [Airflow best practices
documentation](https://airflow.apache.org/docs/apache-airflow/stable/best-practices.html#airflow-variables),
calling `Variable.get()` at module level or inside operator constructor
arguments causes a database query **every time the DAG file is parsed**
by the scheduler. This can degrade parsing performance and even cause
DAG file timeouts. The recommended alternative is to pass variables via
Jinja templates (`{{ var.value.my_var }}`), which defer the lookup until
task execution.
### What the rule flags
```python
from airflow.sdk import Variable
from airflow.operators.bash import BashOperator
# Top-level Variable.get() — runs on every DAG parse
foo = Variable.get("foo")
# Variable.get() in operator constructor args — also runs on every DAG parse
BashOperator(
task_id="bad",
bash_command="echo $FOO",
env={"FOO": Variable.get("foo")},
)
# Variable.get() in a regular helper function — not a task execution context
def helper():
return Variable.get("foo")
```
### What it allows
`Variable.get()` is fine inside code that only runs during task
execution:
- `@task`-decorated functions (including `@task()`, `@task.branch`,
`@task.short_circuit`, etc.)
- `execute()` methods on `BaseOperator` subclasses
### Implementation details
- Resolves `Variable.get` via the semantic model, matching both
`airflow.models.Variable` and `airflow.sdk.Variable` import paths.
- Determines "task execution context" by walking the statement hierarchy
(`current_statements()`) looking for a parent `FunctionDef` that is
either:
- decorated with `@task` / `@task.<variant>` (resolved via
`airflow.decorators.task`), or
- named `execute` inside a class inheriting from `BaseOperator`.
- Handles both `@task` and `@task()` forms via `map_callable`, and
attribute-style decorators like `@task.branch` via `Expr::Attribute`
resolution.
## Test Plan
Added snapshot tests in `AIR005.py` covering:
- **Violations**: `Variable.get()` at module level, inside operator
constructor keyword arguments, inside f-string interpolation in operator
args, and in a regular helper function. Tested both
`airflow.models.Variable` and `airflow.sdk.Variable` import paths.
- **Non-violations**: `Variable.get()` inside `@task`, `@task()`,
`@task.branch` decorated functions, inside a `BaseOperator.execute()`
method, and Jinja template usage (no `Variable.get()` call).
related: https://github.com/apache/airflow/issues/43176
## Summary
This PR renames TID254 to `lazy-import-mismatch` and expands it into a
single rule with two complementary settings: `require-lazy` and
`ban-lazy`. Both settings accept `"all"`, `list[str]`, or `{ include =
..., exclude = [...] }`, and we validate that the two selectors don’t
overlap.
This covers the two main use-cases users asked for:
- Require lazy imports by default, except for known side-effectful
modules. Example:
```toml
require-lazy = { include = "all", exclude = ["sitecustomize"] }
```
This enforces lazy imports everywhere Ruff can rewrite them, while
leaving sitecustomize eager.
- Forbid lazy imports for modules that must stay eager, or even forbid
lazy imports by default with a small allowlist.
```toml
# Require `sitecustomize` to stay eager.
ban-lazy = ["sitecustomize"]
# Ban lazy imports everywhere except `typing`.
ban-lazy = { include = "all", exclude = ["typing"] }
```
## Summary
Add rule AIR304 that flags runtime-varying function calls (e.g.,
`datetime.now()`, `pendulum.now()`, `random.randint()`, `uuid.uuid4()`)
used as arguments in Airflow DAG/task constructors. These calls cause
the serialized DAG hash to change on every parse, creating infinite DAG
versions in the `dag_version` and `serialized_dag` tables, leading to
unbounded database growth and eventual OOM conditions.
Reference: https://github.com/apache/airflow/pull/59430
The rule checks:
- `DAG(...)` constructors and `@dag(...)` decorator calls
- Operator and sensor constructors (via
`is_airflow_builtin_or_provider`)
- `@task(...)` decorator calls
It recursively inspects keyword argument values through binary/unary
ops, dicts, lists, sets, tuples, and f-strings to find calls to known
runtime-varying functions from `datetime`, `pendulum`, `time`, `uuid`,
and `random`.
## Test Plan
`cargo test -p ruff_linter -- airflow::tests` — all 43 tests pass,
including the new AIR304 test case with 17 violation and 6 non-violation
scenarios covering direct calls, binary ops, `default_args` dicts,
f-strings, operators, sensors, decorators, and non-airflow calls.
---
Related: https://github.com/apache/airflow/issues/43176
CC: @Lee-W @sjyangkevin @wjddn279
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
`TID254` enforces the use of `lazy` imports. You can specify a set of
modules, similar to `banned-module-level-imports`, or `"all"`:
```toml
# Require every module-level import to be lazy.
banned-eager-imports = "all"
# Require lazy imports for specific modules.
banned-eager-imports = [
"boto3",
"botocore",
]
```
<!--
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
Adds a new rule os-path-commonprefix (RUF071) that detects calls to
os.path.commonprefix(), which performs character-by-character string
comparison instead of path-component comparison — a well-known footgun.
os.path.commonpath() is the correct alternative.
## Test Plan
cargo nextest run -p ruff_linter -- rule_ospathcommonprefix
Closes#22981
---------
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
## Summary
Closes#13141
Adds a new rule `unnecessary-assign-before-yield` (`RUF070`) that
detects variable assignments immediately followed by a `yield` (or
`yield from`) of that variable, where the variable is not referenced
anywhere else. This is the `yield` equivalent of `RET504`
(`unnecessary-assign`).
```python
# Before
def gen():
x = 1
yield x
# After
def gen():
yield 1
```
Unlike return, yield does not exit the function, so the rule only triggers when the binding has exactly one reference (the yielditself). The fix is marked as unsafe for the same reason.
## Test Plan
cargo nextest run -p ruff_linter -- RUF070
---------
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
## Summary
This PR implements the `consider-swap-variables` rule from pylint.
Basically it tries to find code parts that swap two variables with each
other using a temporary variable.
Example code:
```py
temp = x
x = y
y = temp
```
can be simplified to
```py
x, y = y, x
```
related:
-
https://pylint.readthedocs.io/en/latest/user_guide/messages/refactor/consider-swap-variables.html
- #970
<!-- What's the purpose of the change? What does it do, and why? -->
## Test Plan
I've added new snapshots tests.
PS: Since this is my first contribution here and I'm not too familiar
with the codebase, suggestions are very welcome! The implementation
might also not be 100% memory-optimized yet, since we use `clone` a few
times.
New `extension` configuration option takes a dictionary mapping custom file extensions (keys) to languages by name (values). Eg,
```toml
[tool.ruff]
extension = {qmd="markdown"}
```
Issue #23204
<!--
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
fixes#6371
- `settings.rs` - Added import_headings: FxHashMap<ImportSection,
String> field to Settings struct with default and display support
- `options.rs` - Added import_heading configuration option with
#[option] metadata, documentation, and TOML table syntax. Added
validation for unknown sections and mapping to Settings
- `mod.rs` - Core logic in format_import_block:
- Collects all configured heading values as "# {heading}" strings
- Strips matching heading comments from ALL imports in each section
(handles reordering)
- Inserts heading comments above each section after blank line logic
- `organize_imports.rs` - Extended fix range backward to include heading
comment lines above the first import, preventing duplication on fix
application
## Test Plan
Test Coverage (9 new tests, 156 total passing)
```
cargo test -p ruff_linter -- isort::tests
cargo test -p ruff_linter -- isort::tests::import_heading
```
| Test | Scenario |
|-------------------------------------------|------------------------------------------------------|
| import_heading.py | Basic unsorted imports get headings added |
| import_heading_already_present.py | Existing headings stripped and
re-added correctly |
| import_heading_already_correct.py | Properly sorted+headed imports
produce NO diagnostic |
| import_heading_unsorted.py | Completely unsorted imports get sorted
with headings |
| import_heading_with_no_lines_before.py | Interaction with
no_lines_before setting |
| import_heading_partial.py | Headings configured for only some sections
|
| import_heading_wrong_heading.py | Non-matching comments preserved as
regular comments |
| import_heading_single_section.py | Only one section present gets its
heading |
| import_heading_force_sort_within_sections.py | Works with
force_sort_within_sections |
---------
Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
Co-authored-by: Amethyst Reese <amethyst@n7.gg>
## Summary
This PR is related to the discussion:
https://github.com/apache/airflow/issues/54714
This change creates a new code (`AIR321`) and implement ruff rules to
catch, and/or fix deprecated imports in Airflow for **Airflow 3.1**. The
rules are implemented by following the structure of `AIR301`. The rules
check whether a removed Airflow name is used, and match on `Expr::Name`
and `Expr::Attribute`.
## Test Plan
The following two test files are added:
1. crates/ruff_linter/resources/test/fixtures/airflow/AIR321_names.py
2.
crates/ruff_linter/resources/test/fixtures/airflow/AIR321_names_fix.py
`AIR321_names.py`
All the test cases in this file should raise violations and fixes should
be suggested when running the test with `--unsafe-fixes`. The test
results shown in the snapshot are expected.
```bash
cargo run -p ruff -- check crates/ruff_linter/resources/test/fixtures/airflow/AIR321_names.py --no-cache --preview --select AIR321 --unsafe-fixes
```
`AIR321_names_fix.py `
All the test cases in this file raise NO violation (i.e., all checks
should pass). The snapshot file is empty.
<img width="1330" height="384" alt="Screenshot from 2026-01-04 16-37-51"
src="https://github.com/user-attachments/assets/8a1d6dca-dc69-41f8-ba9f-822e0bcd04a7"
/>
## Document Update
<img width="942" height="56" alt="Screenshot from 2026-01-04 16-37-02"
src="https://github.com/user-attachments/assets/e678837d-895f-483b-94a0-58dde7c60032"
/>
Hello,
This MR adds a new rule and its fix, `RUF069`,
`DuplicateEntryInDunderAll`. I'm using `RUF069` because we already have
[RUF068](https://github.com/astral-sh/ruff/pull/20585) and
[RUF069](https://github.com/astral-sh/ruff/pull/21079#issuecomment-3493839453)
in the works.
The rule job is to prevent users from accidentally adding duplicate
entries to `__all__`, which, for example, can result from copy-paste
mistakes.
It deals with the following syntaxes:
```python
__all__: list[str] = ["a", "a"]
__all__: typing.Any = ("a", "a")
__all__.extend(["a", "a"])
__all__ += ["a", "a"]
```
But it does not keep track of `__all__` contents, meaning the following
code snippet is a false negative:
```python
class A: ...
__all__ = ["A"]
__all__.extend(["A"])
```
## Violation Example
```console
RUF069 `__all__` contains duplicate entries
--> RUF069.py:2:17
|
1 | __all__ = ["A", "A", "B"]
| ^^^
help: Remove duplicate entries from `__all__`
1 | __all__ = ["A", "B"]
- __all__ = ["A", "A", "B"]
```
## Ecosystem Report
The `ruff-ecosystem` results contain seven violations in four projects,
all of them seem like true positives, with one instance appearing to be
an actual bug.
This [code
snippet](https://github.com/python/typeshed/blob/90d855985be5aae9bc76e77b0f3d4b6738c38347/stubs/reportlab/reportlab/lib/rltempfile.pyi#L4)
from `reportlab` contains the same entry twice instead of exporting both
functions.
```python
def get_rl_tempdir(*subdirs: str) -> str: ...
def get_rl_tempfile(fn: str | None = None) -> str: ...
__all__ = ("get_rl_tempdir", "get_rl_tempdir")
```
Closes [#21945](https://github.com/astral-sh/ruff/issues/21945)
---------
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
## Summary
This is a follow up PR to https://github.com/astral-sh/ruff/pull/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"
/>
Summary
--
This PR adds a new rule, `non-empty-init-module`, which restricts the
kind of
code that can be included in an `__init__.py` file. By default,
docstrings,
imports, and assignments to `__all__` are allowed. When the new
configuration
option `lint.ruff.strictly-empty-init-modules` is enabled, no code at
all is
allowed.
This closes#9848, where these two variants correspond to different
rules in the
[`flake8-empty-init-modules`](https://github.com/samueljsb/flake8-empty-init-modules/)
linter. The upstream rules are EIM001, which bans all code, and EIM002,
which
bans non-import/docstring/`__all__` code. Since we discussed folding
these into
one rule on [Discord], I just added the rule to the `RUF` group instead
of
adding a new `EIM` plugin.
I'm not really sure we need to flag docstrings even when the strict
setting is
enabled, but I just followed upstream for now. Similarly, as I noted in
a TODO
comment, we could also allow more statements involving `__all__`, such
as
`__all__.append(...)` or `__all__.extend(...)`. The current version only
allows
assignments, like upstream, as well as annotated and augmented
assignments,
unlike upstream.
I think when we discussed this previously, we considered flagging the
module
itself as containing code, but for now I followed the upstream
implementation of
flagging each statement in the module that breaks the rule (actually the
upstream linter flags each _line_, including comments). This will
obviously be a
bit noisier, emitting many diagnostics for the same module. But this
also seems
preferable because it flags every statement that needs to be fixed up
front
instead of only emitting one diagnostic for the whole file that persists
as you
keep removing more lines. It was also easy to implement in
`analyze::statement`
without a separate visitor.
The first commit adds the rule and baseline tests, the second commit
adds the
option and a diff test showing the additional diagnostics when the
setting is
enabled.
I noticed a small (~2%) performance regression on our largest benchmark,
so I also added a cached `Checker::in_init_module` field and method
instead of the `Checker::path` method. This was almost the only reason
for the `Checker::path` field at all, but there's one remaining
reference in a `warn_user!`
[call](https://github.com/astral-sh/ruff/blob/main/crates/ruff_linter/src/checkers/ast/analyze/definitions.rs#L188).
Test Plan
--
New tests adapted from the upstream linter
## Ecosystem Report
I've spot-checked the ecosystem report, and the results look "correct."
This is obviously a very noisy rule if you do include code in
`__init__.py` files. We could make it less noisy by adding more
exceptions (e.g. allowing `if TYPE_CHECKING` blocks, allowing
`__getattr__` functions, allowing imports from `importlib` assignments),
but I'm sort of inclined just to start simple and see what users need.
[Discord]:
https://discord.com/channels/1039017663004942429/1082324250112823306/1440086001035771985
---------
Co-authored-by: Micha Reiser <micha@reiser.io>
## Summary
- Adds new RUF103 and RUF104 diagnostics for invalid and unmatched
suppression comments
- Reports RUF100 for any unused range suppression
- Reports RUF102 for range suppression comment with invalid rule codes
- Reports RUF103 for range suppression comment with invalid suppression syntax
- Reports RUF104 diagnostics for any unmatched range suppression comment (disable w/o enable)
## Test Plan
Updated snapshots from test cases with unmatched suppression comments
Issue #3711Fixes#21878Fixes#21875
## Summary
implement pylint rule stop-iteration-return / R1708
## Test Plan
<!-- How was it tested? -->
---------
Co-authored-by: Brent Westbrook <brentrwestbrook@gmail.com>
Summary
--
Closes#19467 and also removes the warning about using Python 3.14
without
preview enabled.
I also bumped `PythonVersion::default` to 3.9 because it reaches EOL
this month,
but we could also defer that for now if we wanted.
The first three commits are related to the `latest` bump to 3.14; the
fourth commit
bumps the default to 3.10.
Note that this PR also bumps the default Python version for ty to 3.10
because
there was a test asserting that it stays in sync with
`ast::PythonVersion`.
Test Plan
--
Existing tests
I spot-checked the ecosystem report, and I believe these are all
expected. Inbits doesn't specify a target Python version, so I guess
we're applying the default. UP007, UP035, and UP045 all use the new
default value to emit new diagnostics.
Summary
--
Fixes#20536 by linking between the isort options `case-sensitive` and
`order-by-type`. The latter takes precedence over the former, so it
seems good to clarify this somewhere.
I tweaked the wording slightly, but this is otherwise based on the patch
from @SkylerWittman in
https://github.com/astral-sh/ruff/issues/20536#issuecomment-3326097324
(thank you!)
Test Plan
--
N/a
---------
Co-authored-by: Skyler Wittman <skyler.wittman@gmail.com>
Co-authored-by: Micha Reiser <micha@reiser.io>
## Summary
Adds a new rule to find and report use of `os.path` or `pathlib.Path` in
async functions.
Issue: #8451
## Test Plan
Using `cargo insta test`
<!--
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
Fixes#12734
I have started with simply checking if any arguments that are providing
extra values to the log message are calls to `str` or `repr`, as
suggested in the linked issue. There was a concern that this could cause
false positives and the check should be more explicit. I am happy to
look into that if I have some further examples to work with.
If this is the accepted solution then there are more cases to add to the
test and it should possibly also do test for the same behavior via the
`extra` keyword.
<!-- What's the purpose of the change? What does it do, and why? -->
## Test Plan
I have added a new test case and python file to flake8_logging_format
with examples of this anti-pattern.
<!-- How was it tested? -->
## Summary
Implements new rule `B912` that requires the `strict=` argument for
`map(...)` calls with two or more iterables on Python 3.14+, following
the same pattern as `B905` for `zip()`.
Closes#20057
---------
Co-authored-by: dylwil3 <dylwil3@gmail.com>
## Summary
This PR Removes deprecated UP038 as per instructed in #18727closes#18727
## Test Plan
I have run tests non of them failing
One Question i have is do we have to document that UP038 is removed?
---------
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
## Summary
closes#7710
## Test Plan
It is is removal so i don't think we have to add tests otherwise i have
followed test plan mentioned in contributing.md
---------
Co-authored-by: Brent Westbrook <36778786+ntBre@users.noreply.github.com>
## Summary
Adds new rule to catch use of builtins `input()` in async functions.
Issue #8451
## Test Plan
New snapshosts in `ASYNC250.py` with `cargo insta test`.
## Summary
Adds new rule to find and report use of `httpx.Client` in synchronous
functions.
See issue #8451
## Test Plan
New snapshots for `ASYNC212.py` with `cargo insta test`.