sjyangkevin opened a new pull request, #74689:
URL: https://github.com/apache/airflow/pull/74689
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
<!--
Thank you for contributing!
Please provide above a brief description of the changes made in this pull
request.
Write a good git commit message following this guide:
https://chris.beams.io/posts/git-commit/
Please make sure that your code changes are covered with tests.
And in case of new features or big changes remember to adjust the
documentation.
For user-facing UI changes, please attach before/after screenshots (or a
short
screen recording) so reviewers can assess the visual impact.
Feel free to ping (in general) for the review if you do not see reaction for
a few days
(72 Hours is the minimum reaction time you can expect from volunteers) - we
sometimes miss notifications.
In case of an existing issue, reference it using one of the following:
* closes: #ISSUE
* related: #ISSUE
-->
Several Airflow providers offer `@task.<name>` decorators, such as
`@task.python`, `@task.kubernetes`, `@task.docker` and `@task.llm`. Each
decorator builds an operator behind the scenes, so the options it accepts are
defined in that operator's code. The type hints that editors and type checkers
show for these decorators come from somewhere else: a typing file in the Task
SDK, `task-sdk/src/airflow/sdk/definitions/decorators/__init__.pyi`. No hook
currently checks that the two agree, so as operators and decorators evolve, the
typing file can fall out of step without anyone noticing.
This PR adds a static check that compares the typing file with the source
code of every `@task.<name>` decorator registered in the repository, and brings
the typing file up to date.
closes: #48448
related: #51048
## Current state
Keeping the typing file in sync seems to be a manual step today. As the
issue notes, a step like this is easy to overlook, especially since it applies
to decorators spread across several providers. Comparing the typing file with
the current source shows where it has fallen behind:
- **12 registered decorators don't have a typing entry yet:** `agent`,
`analytics`, `llm`, `llm_batch`, `llm_branch`, `llm_file_analysis`,
`llm_schema_compare`, `llm_sql`, `sftp_sensor`, `snowpark`, `sql` and `stub`.
Editors can't suggest their options, and type checkers don't check them.
- **13 entries don't yet include every option their operators accept.** For
example, the two Kubernetes entries don't include about a dozen
`KubernetesPodOperator` options, such as `callbacks`, `logging_interval` and
`runtime_class_name`.
- **A few entries list options the decorators don't accept.** `@task.docker`
lists `use_dill`, which that decorator doesn't take. `@task.llm_schema_compare`
lists `output_type`, which the operator rejects with a `TypeError`, and
`serialize_output`, whose value the operator always sets itself. In these cases
a type checker accepts code that fails, or has no effect, at runtime.
## What this PR changes
1. Adds a prek hook, `check-task-decorator-arguments`, that fails whenever a
decorator's entry in the typing file doesn't match the options the decorator
actually accepts.
2. Brings the typing file up to date: adds entries for the 12 decorators
that don't have one, and updates 13 existing entries.
3. Updates the reminder comment in `KubernetesPodOperator` to point at the
typing file.
## How the check works
The check needs no list of decorators of its own. For each decorator it
follows the same trail a reader would follow by hand:
1. **Find the decorators.** It reads the `task-decorators` section of every
`provider.yaml`, which is the same list Airflow uses at runtime to find
`@task.<name>` decorators. A new provider or decorator is picked up
automatically.
2. **Find the operator each decorator builds.** It opens the registered
decorator function and finds the class passed as `decorated_operator_class`.
That is the operator created for every task the decorator produces.
3. **Follow the operator's parent classes.** It resolves each parent class
by reading the module's imports, including the compat modules that re-export
classes. This works across providers: the Snowflake decorator's operator, for
example, inherits from the standard provider's `PythonOperator`. It puts the
classes in the order Python itself would call their constructors, and stops at
`BaseOperator`. `BaseOperator` passes nothing further and rejects leftover
arguments, so no class after it can ever receive a user option.
4. **Collect every accepted option.** Each constructor along that path
contributes its parameters, as do the decorator function and Airflow's shared
decorator factory.
5. **Drop the options the decorator fills in itself.** Some options are
always set by the decorator, so users can't pass them:
- Kubernetes always sets `cmds`.
- Bash, SQL and the LLM decorators set `bash_command`, `sql` and `prompt`
from the value the decorated function returns.
- `LLMSchemaCompareOperator` always forces `serialize_output=True`.
The check recognises these because the value passed on is fixed, rather
than taken from the user's input. A value read from the user's arguments, such
as `name=kwargs.pop("name", default)`, stays a user option.
6. **Compare with the typing entry.** It reports options that are missing
from the entry and options the entry lists but the decorator doesn't accept.
Each message names the file and line where the option is defined or filled in.
Everything is read as source code with Python's `ast` module. Nothing is
imported, so the check runs on a clean checkout, without provider dependencies
or Breeze, in about 2 seconds. Example output:
```
__init__.pyi:542: @task.kubernetes is missing parameters: logging_interval.
Declared in providers/cncf/kubernetes/.../operators/pod.py:325.
__init__.pyi:382: @task.docker declares unexpected parameters: command.
command is internally supplied at providers/docker/.../decorators/docker.py:125.
```
## When the check fails
| Situation | What the check reports | What the contributor does |
|---|---|---|
| A new provider ships a decorator, or a provider adds one | The decorator
has no typing entry, or its entry is missing options, each with its source line
| Add the entry |
| A decorator is removed from `provider.yaml` | The typing file still has an
entry for it | Delete the entry |
| A decorator is renamed | The new name needs an entry, and the old entry is
left over | Rename the entry |
| `provider.yaml` points at a missing file or function | The missing file or
function | Fix the registration |
| An operator gains, loses or renames an option | Every decorator built on
it, including decorators in other providers (a `PythonOperator` change affects
9 decorators across 3 providers) | Update those entries |
| An operator gets a new parent class | The new parent's options are picked
up automatically | Update the entries |
| A decorator starts filling an option itself | The option is now
unexpected, with the line where it is filled | Remove it from the entry |
| Code is written in a way the check can't follow, e.g. picking the operator
class at runtime | An explanation of what it couldn't resolve. It never
silently passes | Use the usual pattern |
None of these cases require editing the check itself. Contributors only
touch the typing file, and the message says exactly what to change. The check
also requires plain `@task` to accept the same options as `@task.python`.
<details>
<summary><b>Example runs</b>: real output, one edit per scenario</summary>
Each scenario starts from this branch, makes one edit, and runs `prek run
check-task-decorator-arguments --all-files`. Long paths and option lists are
shortened with `…`.
On this branch as-is:
```
Check registered task decorator coverage and argument declarations....Passed
```
**1. A provider registers a new decorator without a typing entry.** This
scenario uses the `provider.yaml` entry and decorator module from open PR
#72033 (`@task.eks_pod`):
```
Check registered task decorator coverage and argument declarations....Failed
- hook id: check-task-decorator-arguments
- exit code: 1
@task.eks_pod: registered without a named typing declaration. Update
task-sdk/…/decorators/__init__.pyi.
@task.eks_pod: task-sdk/…/decorators/__init__.pyi: expected one
configuration overload TaskDecoratorCollection.eks_pod, found 0
```
After an empty `eks_pod` entry is added, the check lists every option to
declare and where each one comes from, here across two providers:
```
…/__init__.pyi:63: @task.eks_pod is missing parameters:
active_deadline_seconds, affinity, annotations, …, aws_conn_id, …,
cluster_name, …, region, …, xcom_sidecar_container_security_context. Declared
in providers/amazon/…/decorators/eks.py:72,
providers/amazon/…/operators/eks.py:1136,
providers/cncf/kubernetes/…/operators/pod.py:325. Update …/__init__.pyi.
```
**2. An operator gains an option.** Adding `cache_result: bool = False` to
`PythonOperator.__init__` flags every decorator built on it, across three
providers:
```
…/__init__.pyi:226: @task.branch is missing parameters: cache_result.
Declared in providers/standard/…/operators/python.py:183. Update …/__init__.pyi.
…/__init__.pyi:310: @task.branch_external_python is missing parameters:
cache_result. Declared in providers/standard/…/operators/python.py:183. Update
…/__init__.pyi.
…/__init__.pyi:246: @task.branch_virtualenv is missing parameters:
cache_result. Declared in providers/standard/…/operators/python.py:183. Update
…/__init__.pyi.
…/__init__.pyi:177: @task.external_python is missing parameters:
cache_result. Declared in providers/standard/…/operators/python.py:183. Update
…/__init__.pyi.
…/__init__.pyi:962: @task.pyspark is missing parameters: cache_result.
Declared in providers/standard/…/operators/python.py:183. Update …/__init__.pyi.
…/__init__.pyi:64: @task.python is missing parameters: cache_result.
Declared in providers/standard/…/operators/python.py:183. Update …/__init__.pyi.
…/__init__.pyi:360: @task.short_circuit is missing parameters:
cache_result. Declared in providers/standard/…/operators/python.py:183. Update
…/__init__.pyi.
…/__init__.pyi:1032: @task.snowpark is missing parameters: cache_result.
Declared in providers/standard/…/operators/python.py:183. Update …/__init__.pyi.
…/__init__.pyi:107: @task.virtualenv is missing parameters: cache_result.
Declared in providers/standard/…/operators/python.py:183. Update …/__init__.pyi.
```
**3. A typing entry lists an option the decorator always fills itself.**
Adding `cmds` to the `@task.kubernetes` entry:
```
…/__init__.pyi:542: @task.kubernetes declares unexpected parameters: cmds.
cmds is internally supplied at
providers/cncf/kubernetes/…/decorators/kubernetes.py:72. Update …/__init__.pyi
to match
providers/cncf/kubernetes/…/decorators/kubernetes.py:_KubernetesDecoratedOperator
and the constructors it inherits.
```
**4. A decorator is removed.** Deleting the `task-decorators` block from the
Docker provider's `provider.yaml`:
```
@task.docker: typing declaration has no provider registration. Update
task-sdk/…/__init__.pyi.
```
**5. Code the check can't follow.** Choosing the operator class at runtime
with `decorated_operator_class=_get_operator_class()`:
```
@task.docker: providers/docker/…/decorators/docker.py:213: cannot resolve
decorated_operator_class _get_operator_class()
```
**When the check deliberately passes:**
**6. A new option on `BaseOperator`.** Adding `new_shared_option` to
`BaseOperator.__init__` → `Passed`. Shared options go through each entry's
`**kwargs`, so the 25 entries don't need editing.
**7. A new operator option that the decorator fills itself.** Adding
`script_path` to `BashOperator`, with `_BashDecoratedOperator` passing
`script_path=SET_DURING_EXECUTION` → `Passed`. Users can't set the option, so
the entry must not list it. Without the decorator's line, the same edit fails
with `@task.bash is missing parameters: script_path`.
**8. Changing an option's type or default.** Changing `image: str | None =
None` to `image: str = "busybox"` in `KubernetesPodOperator` → `Passed`. Only
option names are compared; see below.
</details>
## What the check does not cover
- **Option types and default values.** Only option names are compared. A few
entries on `main` have types narrower than their operators. For example,
`@task.sensor` declares `poke_interval: float` although `timedelta` works.
Those can be fixed in a follow-up.
- **Options shared by every operator** (`task_id`, `retries`, `pool`, …).
The typing entries deliberately accept these through their trailing `**kwargs`
instead of listing about 55 options in every entry. The check keeps that
convention: it stops at `BaseOperator`, and it reports a shared option listed
in a single entry as unexpected.
- **`@task.run_if` and `@task.skip_if`.** They wrap another task rather than
creating an operator, and their signatures are defined in the Task SDK itself.
- **Decorators from providers outside this repository.** They still work at
runtime, but this repository's typing file can't describe them.
- **Options an operator reads from `**kwargs` without declaring them.** This
happens in 3 operator classes across the providers (two Databricks sensors and
one Cloud Composer sensor). None of them has a decorator today.
- **Other decorator families**, such as `@dag`, `@task_group` and `@asset`.
## Impact on CI and pre-commit
- **CI:** the static checks job already runs `prek --all-files`, so this is
one more hook taking about 2 seconds. It needs no Breeze image and imports no
provider code.
- **Local commits:** the hook uses `always_run: true`, so it runs on every
commit, adding about 2 seconds. This is needed because deleted files are not
passed to hooks, and removing a decorator's files would otherwise go unnoticed.
The check always scans all decorators, because a change to one shared file,
such as `PythonOperator`, the decorator factory or the typing file itself, can
affect decorators in several providers.
- **When it can block a PR:** when that PR leaves the typing file out of
sync, which is the intended case. It can also block a PR when two open PRs each
pass on their own but conflict once both are merged. For example, open PR
#72033 registers a new `@task.eks_pod` decorator without a typing entry, so
whichever of the two PRs merges second will need to add that entry. The error
message lists exactly what to add.
- **Coverage today:** all 25 `@task.<name>` decorators across 8 providers.
Every module that builds a decorator with the shared factory is registered in a
`provider.yaml`, so nothing is left unchecked.
## Question for reviewers: newsfragment?
The typing changes alter what type checkers accept for `@task.<name>` calls.
Twelve decorators now have typed signatures, and options the decorators don't
accept were removed. Runtime behaviour does not change. Since the Task SDK
ships with airflow-core, would you like an `airflow-core/newsfragments` entry
for this, and if so of which type? I haven't added one, following the guideline
to add newsfragments only when the change is clearly user-facing.
## Testing
- `uv run --project scripts pytest scripts/tests/`: 1,814 passed, 1 skipped.
This includes 133 tests for the new hook. Each rule in the check was also
disabled in turn to confirm that a test fails (13 of 13).
- `prek run --from-ref <base> --stage pre-commit`: passed.
- `prek run --from-ref <base> --stage manual --skip
upgrade-important-versions`: passed, except `mypy-providers`, which couldn't
run because my local Breeze CI image is out of date. The only provider file it
would check is `pod.py`, where the only change is a comment. CI runs it.
- `prek run mypy-task-sdk --all-files`: passed. This type-checks the typing
file.
- `breeze build-docs task-sdk`: passed. The Task SDK API docs are generated
from the typing file.
- Ran the derivation over all 1,262 operator and sensor classes in the
providers: every one resolved through to `BaseOperator`.
- Not run locally: the core and provider test suites `breeze verify` selects
(Kubernetes, Amazon, Google, …). They are selected because of the `.pyi` file
and a comment-only change in `pod.py`, neither of which changes runtime
behaviour. CI runs them.
---
##### Was generative AI tooling used to co-author this PR?
- [X] Yes — Claude Code (Opus 5.5)
Generated-by: Claude Code (Opus 5.5) following [the
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
---
* Read the **[Pull Request
Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)**
for more information. Note: commit author/co-author name and email in commits
become permanently public when merged.
* For fundamental code changes, an Airflow Improvement Proposal
([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals))
is needed.
* When adding dependency, check compliance with the [ASF 3rd Party License
Policy](https://www.apache.org/legal/resolved.html#category-x).
* For significant user-facing changes create newsfragment:
`{pr_number}.significant.rst`, in
[airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).
You can add this file in a follow-up commit after the PR is created so you
know the PR number.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]