Repository navigation
Add static check to keep @task decorator type hints in sync with their operators - #74689
Draft
sjyangkevin wants to merge 3 commits into
Draft
sjyangkevin wants to merge 3 commits into
sjyangkevin wants to merge 3 commits into
Conversation
sjyangkevin
force-pushed
the
check-kubernetes-decorator-arguments
branch
2 times, most recently
from
October 14, 2026 18:36
2274cec to
82f7dd0
Compare
Decorator options passed through kwargs can work at runtime while missing from typing declarations, leaving users without completion and argument type checks. Detecting drift during static checks makes those omissions visible to authors and reviewers.
A hand-written mapping from each decorator to its operator classes had to be extended for every new decorator, moved module or new parent class, and any entry nobody added quietly narrowed what the check covered. Deriving those relationships from provider metadata and the source code keeps coverage complete without maintaining the checker, and comparing against the current source showed the typing file had fallen behind for most registered decorators.
sjyangkevin
force-pushed
the
check-kubernetes-decorator-arguments
branch
from
October 15, 2026 00:23
82f7dd0 to
244b77a
Compare
LLMOperator replaced agent_params with model_settings and output_retries, so the typing entries for the LLM decorators still offered an option the operator now rejects and were missing the two new ones.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Several Airflow providers offer
@task.<name>decorators, such as@task.python,@task.kubernetes,@task.dockerand@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:
agent,analytics,llm,llm_batch,llm_branch,llm_file_analysis,llm_schema_compare,llm_sql,sftp_sensor,snowpark,sqlandstub. Editors can't suggest their options, and type checkers don't check them.KubernetesPodOperatoroptions, such ascallbacks,logging_intervalandruntime_class_name.@task.dockerlistsuse_dill, which that decorator doesn't take.@task.llm_schema_comparelistsoutput_type, which the operator rejects with aTypeError, andserialize_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
check-task-decorator-arguments, that fails whenever a decorator's entry in the typing file doesn't match the options the decorator actually accepts.KubernetesPodOperatorto 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:
Find the decorators. It reads the
task-decoratorssection of everyprovider.yaml, which is the same list Airflow uses at runtime to find@task.<name>decorators. A new provider or decorator is picked up automatically.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.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 atBaseOperator.BaseOperatorpasses nothing further and rejects leftover arguments, so no class after it can ever receive a user option.Collect every accepted option. Each constructor along that path contributes its parameters, as do the decorator function and Airflow's shared decorator factory.
Drop the options the decorator fills in itself. Some options are always set by the decorator, so users can't pass them:
cmds.bash_command,sqlandpromptfrom the value the decorated function returns.LLMSchemaCompareOperatoralways forcesserialize_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.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
astmodule. Nothing is imported, so the check runs on a clean checkout, without provider dependencies or Breeze, in about 2 seconds. Example output:When the check fails
provider.yamlprovider.yamlpoints at a missing file or functionPythonOperatorchange affects 9 decorators across 3 providers)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
@taskto accept the same options as@task.python.Example runs: real output, one edit per scenario
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:
1. A provider registers a new decorator without a typing entry. This scenario uses the
provider.yamlentry and decorator module from open PR #72033 (@task.eks_pod):After an empty
eks_podentry is added, the check lists every option to declare and where each one comes from, here across two providers:2. An operator gains an option. Adding
cache_result: bool = FalsetoPythonOperator.__init__flags every decorator built on it, across three providers:3. A typing entry lists an option the decorator always fills itself. Adding
cmdsto the@task.kubernetesentry:4. A decorator is removed. Deleting the
task-decoratorsblock from the Docker provider'sprovider.yaml:5. Code the check can't follow. Choosing the operator class at runtime with
decorated_operator_class=_get_operator_class():When the check deliberately passes:
6. A new option on
BaseOperator. Addingnew_shared_optiontoBaseOperator.__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_pathtoBashOperator, with_BashDecoratedOperatorpassingscript_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 = Nonetoimage: str = "busybox"inKubernetesPodOperator→Passed. Only option names are compared; see below.What the check does not cover
mainhave types narrower than their operators. For example,@task.sensordeclarespoke_interval: floatalthoughtimedeltaworks. Those can be fixed in a follow-up.task_id,retries,pool, …). The typing entries deliberately accept these through their trailing**kwargsinstead of listing about 55 options in every entry. The check keeps that convention: it stops atBaseOperator, and it reports a shared option listed in a single entry as unexpected.@task.run_ifand@task.skip_if. They wrap another task rather than creating an operator, and their signatures are defined in the Task SDK itself.**kwargswithout 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.@dag,@task_groupand@asset.Impact on CI and pre-commit
prek --all-files, so this is one more hook taking about 2 seconds. It needs no Breeze image and imports no provider code.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 asPythonOperator, the decorator factory or the typing file itself, can affect decorators in several providers.@task.eks_poddecorator 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.@task.<name>decorators across 8 providers. Every module that builds a decorator with the shared factory is registered in aprovider.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 anairflow-core/newsfragmentsentry 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, exceptmypy-providers, which couldn't run because my local Breeze CI image is out of date. The only provider file it would check ispod.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.BaseOperator.breeze verifyselects (Kubernetes, Amazon, Google, …). They are selected because of the.pyifile and a comment-only change inpod.py, neither of which changes runtime behaviour. CI runs them.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5.5) following the guidelines
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.