Skip to content

Improve error handling for SUPERVISOR_COMMS access outside task context - #61630

Open
andreahlert wants to merge 10 commits into
apache:mainfrom
andreahlert:fix/supervisor-comms-import-error
Open

Improve error handling for SUPERVISOR_COMMS access outside task context#61630
andreahlert wants to merge 10 commits into
apache:mainfrom
andreahlert:fix/supervisor-comms-import-error

Conversation

@andreahlert

@andreahlert andreahlert commented Feb 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Related to #51816

SUPERVISOR_COMMS is declared as a bare type annotation (SUPERVISOR_COMMS: CommsDecoder[...]) in task_runner.py, which does not create an actual module attribute in Python. This caused ImportError when Variable.get() was called at the top level of a DAG file (outside task execution context), e.g. during dag.test().

This PR improves error handling and messaging for this scenario but does not address the root cause. A separate PR will implement the actual fix using a lazy-init approach with InProcessExecutionAPI.

Changes

  • Switch callers to from airflow.sdk.execution_time import task_runner and gate every SUPERVISOR_COMMS.send(...) behind hasattr(task_runner, "SUPERVISOR_COMMS"). Avoids the bare-annotation ImportError without changing the runtime semantics for in-task callers.
  • Guard _get_variable, _get_variable_keys, _set_variable, and _delete_variable at the top of the function so the friendly error is raised before any secrets-backend work (in particular, _set_variable no longer emits the misleading "API Server will be updated" warning when there is no SUPERVISOR_COMMS to send the write through).
  • Improve error messages when Variable.get/keys/set/delete is called outside task context, suggesting alternatives (environment variables, Jinja templates, moving the call inside a task).
  • Add tests for Variable.get/set/delete outside task execution context.
  • Align Variable/Connection with supervisor logic: airflow.models.Variable and Connection keep using the same hasattr(..., "SUPERVISOR_COMMS") check as supervisor.py, so outside task context the legacy path is used and provider CI is unaffected.

Files changed

File Change
context.py task_runner namespace + hasattr guards + enhanced error messages for get/keys/set/delete
execution_api.py Defensive guards in ExecutionAPISecretsBackend
test_variables.py New tests for outside-task-context behavior
airflow-core/.../task_sdk_context.py Helper is_in_task_sdk_execution_context() shared with airflow.models.Variable / Connection

Out-of-scope change included

airflow-core/tests/unit/serialization/test_serialized_objects.py contains an unrelated fix for test_logging_propogated_by_default flakiness: filtering caplog.records by the airflow.task.operators logger so OTLP trace-export errors from other loggers no longer pollute the assertion. It was caught while debugging the CI flake blocking this PR. Not split into a separate PR to avoid an extra CI cycle for a one-line test scoping change; flagged here for reviewer awareness (commit f2f1f3c5c9).

Test plan

  • Existing test_variables.py tests pass (no regression)
  • New TestVariableOutsideTaskContext tests pass
  • Variable.get() with env var works outside task context
  • Variable.get() / Variable.keys() / Variable.set() / Variable.delete() raise the friendly AirflowRuntimeError (no ImportError) outside task context

@SameerMesiah97 SameerMesiah97 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix looks reasonable in isolation, but several provider tests appear to have relied on the previous permissive behavior when SUPERVISOR_COMMS was absent or not initialized. By normalizing it to None, execution-time paths (particularly around connection resolution) are now enforced and fail loudly, which is causing widespread provider CI failures. It seems those tests did not anticipate SUPERVISOR_COMMS being present but None and therefore entering these stricter code paths. It appears that provider tests might have to be loosened (or maybe connection resolution migh have to check for SUPERVISOR_COMMS) to accomodate this change but I am not going to make such a sweeping suggestion unilaterally.

@andreahlert
andreahlert requested a review from XD-DENG as a code owner March 3, 2026 19:22
@andreahlert

Copy link
Copy Markdown
Contributor Author

This fix looks reasonable in isolation, but several provider tests appear to have relied on the previous permissive behavior when SUPERVISOR_COMMS was absent or not initialized. By normalizing it to None, execution-time paths (particularly around connection resolution) are now enforced and fail loudly, which is causing widespread provider CI failures. It seems those tests did not anticipate SUPERVISOR_COMMS being present but None and therefore entering these stricter code paths. It appears that provider tests might have to be loosened (or maybe connection resolution migh have to check for SUPERVISOR_COMMS) to accomodate this change but I am not going to make such a sweeping suggestion unilaterally.

Thanks for the feedback. Already handled it with is_in_task_sdk_execution_context(), which checks that SUPERVISOR_COMMS is not None. Variable and Connection use it now, so provider tests keep the legacy path.

@tatiana tatiana left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreahlert Thank you very much for working on this!

I believe this is a meaningful improvement to Airflow's error handling. The change provides a clearer error message and suggests a possible solution for end users, which is definitely helpful.

That said, my impression is that this PR does not solve the root cause of the issue discussed in #51816. Because of this, I think the PR description should probably be updated from "Fixes" to "Related", and maybe we should make the title more descriptive, this is an error handling improvement.

The core issue is that there are valid use cases where accessing Airflow Variables during DAG parsing is necessary. Examples include:

@kaxil confirmed that these are valid use cases here:
#51816 (comment)

Since we allow that in the actual dag parsing models (as it goes via DAG processor -> Supervisor comms -> Variable), we should do the same for dag.test.

@ashb gave a hint on a possible solution in #51816 (comment)

So now back to the main question: can we "just" make it set up an in-process API server there to try and ask the local DB in that case? Are there any security risks of doing that?

Because of this, my understanding is that the underlying problem remains unresolved, and this PR mainly improves the user-facing error message rather than fixing the behaviour itself.

@andreahlert

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, Tatiana. I'll update this PR to "Related to" instead of "Fixes" and adjust the title accordingly. For the actual fix, I'll open a separate PR implementing the lazy-init approach via __getattr__ on task_runner.py, reusing the existing InProcessExecutionAPI to spin up an in-process server at parse time when running as __main__. The building blocks are already there with InProcessTestSupervisor. The open question is scoping the security model, since the Execution API relies on task identity and there's no task context during parsing. I'll need input from @ashb and @kaxil on that before moving forward.

@andreahlert andreahlert changed the title Fix ImportError when importing SUPERVISOR_COMMS outside task context Improve error handling for SUPERVISOR_COMMS access outside task context Mar 12, 2026
Comment thread task-sdk/src/airflow/sdk/execution_time/supervisor.py Outdated
Comment thread task-sdk/src/airflow/sdk/execution_time/task_runner.py Outdated
@andreahlert
andreahlert requested a review from ashb March 19, 2026 14:08
@andreahlert

Copy link
Copy Markdown
Contributor Author

Friendly ping to my fellow friends: @kaxil @amoghrajesh @XD-DENG @ashb

@potiuk potiuk modified the milestones: Airflow 3.1.9, Airflow 3.2.0 Apr 2, 2026
@potiuk potiuk added the ready for maintainer review Set after triaging when all criteria pass. label Apr 2, 2026
@vatsrahul1001

Copy link
Copy Markdown
Contributor

Moved milestone to 3.2.1 as there is no review from maintainers and I am just about to start rc2 for 3.2.0

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@vatsrahul1001

Copy link
Copy Markdown
Contributor

Looks like all review comments have been addressed. @ashb and @amoghrajesh could you review again?

@potiuk
potiuk marked this pull request as draft May 18, 2026 10:48
@potiuk

potiuk commented May 18, 2026

Copy link
Copy Markdown
Member

@andreahlert — Removing the ready for maintainer review label and converting back to draft. The branch now has merge conflicts with main that surfaced after the label was added.

The label's contract is that the PR is ready for maintainer review — a regression like this means the PR temporarily isn't. Rebase your branch onto the latest main, resolve conflicts, then mark "Ready for review" again to re-enter the queue.

git fetch upstream main && git rebase upstream/main, resolve, git push --force-with-lease. See the working-with-git docs.

No rush.


Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes. Once you have addressed the points above, an Apache Airflow maintainer — a real person — will take the next look at your PR. We use this two-stage triage process so that our maintainers' limited time is spent where it matters most: the conversation with you.

SUPERVISOR_COMMS was declared as a bare type annotation without
assignment, which does not create an actual module attribute in Python.
This caused ImportError when Variable.get() was called at the top level
of a DAG file (outside task execution context), such as during
dag.test().

Initialize SUPERVISOR_COMMS to None so the import always succeeds, add
None guards before .send() calls in _set_variable, _delete_variable,
and ExecutionAPISecretsBackend methods, and provide helpful error
messages suggesting alternatives like environment variables or Jinja
templates.

Closes: apache#51816

Signed-off-by: André Ahlert <andre@aex.partners>
…ruff format

Use type: ignore[assignment] instead of | None for SUPERVISOR_COMMS to avoid 39 mypy union-attr errors. Replace hasattr with getattr is not None in variable.py and connection.py. Fix reinit_supervisor_comms to check is None. Simplify set_supervisor_comms context manager. Update test assertions.

Signed-off-by: André Ahlert <andre@aex.partners>
Assert only on operator logger messages so CI logs (e.g. OTLP connection
errors to localhost:4318) do not break the test.

Signed-off-by: André Ahlert <andre@aex.partners>
Signed-off-by: André Ahlert <andre@aex.partners>
Per review feedback: keep SUPERVISOR_COMMS as a bare type annotation
(not initialized to None) and use hasattr checks in the few places
that need to detect task execution context.

Signed-off-by: André Ahlert <andre@aex.partners>
Signed-off-by: André Ahlert <andre@aex.partners>
@andreahlert
andreahlert force-pushed the fix/supervisor-comms-import-error branch from 70fdb01 to 5631eed Compare May 19, 2026 14:49
Signed-off-by: André Ahlert <andre@aex.partners>
@andreahlert
andreahlert marked this pull request as ready for review May 20, 2026 09:10
…s-import-error

Signed-off-by: André Ahlert <andre@aex.partners>
@potiuk potiuk added the ready for maintainer review Set after triaging when all criteria pass. label May 24, 2026
@potiuk

potiuk commented May 24, 2026

Copy link
Copy Markdown
Member

@ashb — You commented inline on this PR back in March and @tatiana called it "a meaningful improvement to Airflow's error handling". The PR has been sitting at REVIEW_REQUIRED since then with green CI and no unresolved threads. Could you convert your engagement to a formal APPROVE so it can land? Same situation as the sister PR #61627 from @andreahlert.


Note: This comment was drafted by an AI-assisted triage tool and may contain mistakes.


Drafted-by: Claude Code (Opus 4.7); reviewed by @potiuk before posting


raise AirflowRuntimeError(
ErrorResponse(error=ErrorType.VARIABLE_NOT_FOUND, detail={"message": f"Variable {key} not found"})
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related: _get_variable_keys below (line 329) still has the bare from airflow.sdk.execution_time.task_runner import SUPERVISOR_COMMS at the top of the function. Since SUPERVISOR_COMMS is now a bare annotation in task_runner.py (SUPERVISOR_COMMS: CommsDecoder[...] at line 930), this import raises ImportError at function entry whenever the function is called outside a task context -- exactly the opaque failure mode this PR is trying to wrap with a helpful message.

Variable.keys() from a DAG top-level (or any non-task caller) will hit the original ImportError, not the friendly message added to _get_variable here. Worth applying the same hasattr(task_runner, "SUPERVISOR_COMMS") guard there for symmetry, or switching to from airflow.sdk.execution_time import task_runner + task_runner.SUPERVISOR_COMMS like _set_variable / _delete_variable now do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that one slipped through. Fixed.

log.exception(e)

SUPERVISOR_COMMS.send(PutVariable(key=key, value=value, description=description))
if not hasattr(task_runner, "SUPERVISOR_COMMS"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard fires after the conflict-check loop above has already iterated every secrets backend and potentially emitted the log.warning("The variable %s is defined in the %s secrets backend, which takes precedence...") message -- even though we're about to bail out because there is no SUPERVISOR_COMMS to send PutVariable through.

Compare with _delete_variable just below, which guards at the top of the function before any work. For consistency (and to avoid the misleading warning about a write that never happens), hoist this hasattr check above the conflict-check loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Done.

# leaking a lot of state). Only assert on the operator's logger so other loggers (e.g. OTLP trace
# export errors in CI) do not affect the test.
operator_logger_prefix = "airflow.task.operators"
operator_messages = [r.message for r in caplog.records if r.name.startswith(operator_logger_prefix)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This OTLP-noise filter is unrelated to the SUPERVISOR_COMMS error-handling scope of the PR. It's a legitimate CI-flake fix (other loggers polluting caplog.messages), but bundling it here makes the change harder to bisect later if it ever causes regressions.

Either split it into its own PR or call it out explicitly in the description so reviewers know it's intentional scope creep.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, the scope creep is real. Kept it in this PR since it surfaced while debugging the CI flake that was blocking the merge, but called it out explicitly under "Out-of-scope change included" in the description, referencing commit f2f1f3c so it stays discoverable if it ever needs to be bisected.

…t() guard

- _get_variable_keys: replace `from ... import SUPERVISOR_COMMS` (raises
  ImportError, bare annotation) with `task_runner` namespace + hasattr
  guard; previously Variable.keys() outside task context hit the same
  opaque failure this PR is trying to wrap.
- _set_variable: hoist hasattr guard above the secrets-backend
  conflict-check loop so the misleading "API Server will be updated"
  warning is not emitted when the write cannot happen.

Matches the pattern already applied to _get_variable and _delete_variable.

Signed-off-by: André Ahlert <andre@aex.partners>
@andreahlert
andreahlert requested a review from kaxil May 25, 2026 03:13
@potiuk

potiuk commented Jun 25, 2026

Copy link
Copy Markdown
Member

@andreahlert — I've removed the ready for maintainer review label because the next step here is yours: this branch now has merge conflicts with main. It'll go back into the maintainer queue automatically once you rebase onto the latest main and push — no need to re-add the label by hand. There's no rush.

Automated triage note drafted by an AI-assisted tool — may get things wrong; a real Apache Airflow maintainer takes the next look once it's green. (why automated)


Drafted-by: Claude Code (Opus 4.8); reviewed by @potiuk before posting

@potiuk potiuk removed the ready for maintainer review Set after triaging when all criteria pass. label Jun 25, 2026
…s-import-error

# Conflicts:
#	task-sdk/tests/task_sdk/definitions/test_variables.py

Signed-off-by: André Ahlert <andre@aex.partners>
@vatsrahul1001

Copy link
Copy Markdown
Contributor

@andreahlert I see CI is failing can you fix this?

@eladkal

eladkal commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@andreahlert there are conflicts and test failures to resolve

nredd added a commit to nredd/pytest-airflow-in-a-box that referenced this pull request Aug 17, 2026
* Resolve Variables and Connections in top-level Dag code

Changes:
- Add `_compat/parse_time.py` with `ParseTimeComms` and `parse_time_supervision`, which
  install a metastore-backed `SUPERVISOR_COMMS` endpoint for the duration of a Dag parse
- Extract `_lookup_variable`/`_lookup_connection` hooks from `FakeSupervisorComms` so the
  parse-time shim reuses its response shaping
- Wire `build_dag_bag(path, comms=...)` through `collection.py`, `fixtures/dagbag.py`, and
  `smoke.py`, the four places the plugin executes user Dag modules
- Add the `airflow_parse_secrets` fixture for lookups outside a file parse
- Add `--airflow-parse-secrets` and the matching ini option, `metastore` or `off`
- Certify `secrets_resolution` and require `airflow.sdk.execution_time.context._get_variable`,
  `._get_connection`, and `cache.SecretCache` so apache/airflow#61630 fails loudly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Harden parse-time secrets against Airflow's swallowed lookup errors

Changes:
- Log the real cause of a failed lookup at `error` before re-raising it, since Airflow's
  worker secrets backend does a bare `except Exception: return None` and would otherwise
  present a downed metadata database as an ordinary unseeded row
- Move the supervision block outside `build_dag_bag`'s construction `try`, so a teardown
  failure is not relabeled `DagBagConstructionError` over a correctly parsed DagBag
- Log and swallow a failing session close in `parse_time_supervision`, and drop the session
  reference from `ParseTimeComms.close` in a `finally`
- Filter the connection payload to the fields the release's `ConnectionResult` declares;
  `description` is seedable but undeclared, and the models forbid extras before 3.3.0
- Restore Airflow's secrets cache with `SecretCache.init` after resetting it, instead of
  leaving it disabled for the rest of the process
- Initialize the metadata database before `_build_smoke_corpus` parses, so a top-level
  lookup does not charge the one-time migration to a smoke item's parse timeout
- Log an unseeded lookup at `debug`, not `warning`; `Variable.get(key, default=...)` makes
  a miss an ordinary outcome
- Lead the seeding guide with session-scoped seeding, since `full_dag_bag` stashes its bag
  for the whole worker and the function-scoped recipe only holds for the first test
- Add the `0.8.0` changelog link references

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Add run_dag fixture and lead docs with testing real Dags (#176)

* Document pytest's shared-tmpdir GC race for concurrent local runs (#169)

* Document pytest's shared-tmpdir GC race for concurrent local runs

Changes:
- Add a `## Concurrent local runs` section to `docs/development.md` describing
  the `FileNotFoundError: .../pytest-current` race from
  `_pytest/pathlib.py::cleanup_dead_symlinks`, its cause (pytest's shared
  `$TMPDIR/pytest-of-<user>/` numbered-directory GC, not this plugin or
  `AIRFLOW_HOME`), and the `TMPDIR`-per-checkout workaround along with its
  `AIRFLOW_HOME` storage-ladder tradeoff.
- Add a `CHANGELOG.md` entry under `[Unreleased]`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Attribute the tmpdir race to this plugin's retention default, not just pytest

Changes:
- Correct `docs/development.md`'s "Concurrent local runs" section: the race is
  pytest's own unguarded `unlink()`, but this plugin's zero-ini
  `tmp_path_retention_policy = "failed"` default (pytest's own default is
  `all`) is what reliably dangles the `pytest-current` symlink by deleting a
  passing session's own numbered directory in `pytest_sessionfinish` --
  without it the race essentially can't arise.
- Add `pytest -o tmp_path_retention_policy=all` and `PYTEST_DEBUG_TEMPROOT` as
  cheaper workarounds ahead of `TMPDIR`, since neither touches the
  `AIRFLOW_HOME` storage ladder the way overriding `TMPDIR` does.
- Update the matching `CHANGELOG.md` entry to name all three workarounds.

An adversarial review round surfaced this: the doc as first drafted said "not
this plugin" for causation, which undersold this plugin's own default as the
actual trigger for the dangling symlink precondition. Verified directly
against `_pytest/tmpdir.py::pytest_sessionfinish` and this plugin's
`defaults.py`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* Add a retry-behavior cookbook recipe (#170)

* Add a retry-behavior cookbook recipe

Changes:
- Add `## Retry behavior` to `docs/guide/cookbook.md`, driving a retry-configured
  task through `fail -> up_for_retry -> succeed` via a second, explicit
  `dag_maker.run_ti(..., ignore_ti_state=True, ignore_task_deps=True)` call
- Add the backing `test_run_ti_retries_a_failed_task_instance_to_success` to
  `tests/enduser/test_dag_run_result.py`, asserting `try_number`, `retry_delay`
  via `next_retry_datetime()`, and the user's `on_retry_callback` firing, all
  state math with no wall-clock wait
- Note the CHANGELOG entry

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix retry-recipe try_number bookkeeping for review findings

Changes:
- Drop the duplicated `xcom_pull` assertion in the new retry test
- Bump `try_number` before both `run_ti` calls (not just the second) so it
  matches `Dag.test()`'s own per-attempt bookkeeping instead of trailing it
  by one, and assert the resulting `1`/`2` sequence
- Mark the new test `requires_airflow3`: pre-2.10 Airflow exposes
  `try_number` as a read-only derived property rather than a plain column,
  so the exact-value asserts do not hold on the 2.7-2.9 compat legs
- Mirror both fixes in the `docs/guide/cookbook.md` recipe

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Add run_dag fixture and lead docs with testing real Dags

Changes:
- Add a `run_dag` fixture that persists an externally-authored Dag (typically
  pulled from `full_dag_bag`) through a full DagRun and returns the same
  `DagRunResult` snapshot `dag_maker.run()` does, reusing the already
  dag-agnostic `_compat/dag.py` persist/create/execute primitives
- Add the `RunDag` protocol type alongside `RunTask`/`DagMaker`
- Rework the README quickstart, `docs/index.md`, and
  `docs/guide/task-execution.md` so the first example loads an existing Dag
  via `full_dag_bag` + `run_dag`; the inline `dag_maker` example moves to a
  clearly labeled secondary "adhoc Dag" path
- Add a "Testing a Dag defined elsewhere" walkthrough documenting the
  `--dag-folder`/`airflow_dags_folder` option and the same-`dag_id`
  cross-worker xdist constraint `run_dag` inherits from `ensure_dag_absent`

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix run_dag session leak, weak test, and inaccurate xdist race docs

Changes:
- Close `_DagRunner`'s metadata session when `persist_dag` fails, mirroring
  `_DagContext.__exit__`'s existing `finally` guard; the previous unguarded
  call leaked the session and dropped the record from teardown tracking
- Extract `_close_records` shared by `_DagFactory.close`/`_DagRunner.close`,
  removing the byte-for-byte duplicated cleanup loop
- Strengthen `test_run_dag_passes_through_run_id_logical_date_and_dag_run_kwargs`
  to assert on `conf`, a field `create_dag_run` never defaults -- the previous
  `state=RUNNING` value was identical to the default and asserted nothing
- Correct the "same dag_id across xdist workers" race description in
  `docs/guide/task-execution.md` and the `RunDag` docstring: the loser is not
  guaranteed a clean `ValueError` -- both workers can pass the absence check
  and silently share one bundle row, with the first to tear down deleting
  metadata the other's still-running test depends on

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Colocate the smoke catalog with full_dag_bag under --dist loadgroup (#173)

* Colocate the smoke catalog with full_dag_bag under --dist loadgroup

Changes:
- Add `_colocate_smoke_catalog_with_full_dag_bag` to `plugin.py`, forcing the
  synthesized smoke catalog and any `full_dag_bag` consumer onto a shared
  `xdist_group` whenever both exist in a run, unless an item already carries
  its own explicit group
- Add `FULL_DAG_BAG_FIXTURE_NAME` and `FULL_DAG_BAG_XDIST_GROUP` to
  `fixtures/dagbag.py`
- Identify the synthesized smoke items by collection-time identity diff
  rather than the public `smoke` marker, which user tests can also carry
- Document the new co-location behavior in `docs/guide/smoke-tests.md` and
  `CHANGELOG.md`

Closes #163.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix review findings in the smoke/full_dag_bag xdist colocation

Changes:
- Group the catalog with only one full_dag_bag consumer, not every one, so a
  suite with many such consumers does not have all their execution
  serialized onto a single worker just to save one parse
- Detect `--dist=loadgroup` via both `config.option.dist` and xdist's
  worker-side synthetic `config.option.loadgroup`, since
  `xdist.remote.setup_config` resets `dist` back to `"no"` on a real worker
- Predict `-m` survival before deciding co-location, since `_pytest.mark`'s
  deselection hook is normal priority and runs after this plugin's
  `tryfirst` one -- avoids grouping the catalog with a consumer `-m` is
  about to drop
- Gate marker addition on `--dist=loadgroup` actually being active, so
  `-p no:xdist` with `--strict-markers` no longer aborts on an unregistered
  `xdist_group` marker
- Strip xdist's `--dist loadgroup` `@<group>` nodeid suffix in `record.py`
  before keying `--airflow-record` outcomes, so a co-located item's recorded
  nodeid matches the same test recorded under any other run mode
- Simplify smoke-item identification to a tail slice of `items`, re-filtered
  against `--airflow-baseline-select`'s own deselection

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* Add `airflow_smoke_disable` to opt out of bundled smoke items (#171)

* Add `airflow_smoke_disable` to opt out of bundled smoke items

Changes:
- Add `airflow_smoke_disable` ini option, a list of bundled smoke item
  names to drop from the catalog (`type="linelist"`, default empty)
- Add `_disabled_smoke_items` reader and `_SMOKE_ITEM_NAMES` catalog
  constant, validating unknown names at startup
- Gate every yield in `SmokeCollector.collect()` on the disabled set
- Add `_smoke_serialization_needed`, deriving whether any collected item
  still needs a serialized Dag, and use it in `_build_smoke_corpus` and
  `_serialized_dag_cache` to skip calling the Airflow DAG serializer
  entirely once nothing needs it
- Document the new option in `docs/guide/smoke-tests.md`, contrasting it
  with `--deselect` (which filters after the corpus is already built)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix review findings in the smoke-disable opt-out

Changes:
- Guard `_validate_smoke_options` on `_disabled_smoke_items(config)`
  unconditionally, so a malformed `airflow_smoke_disable` is always
  reported even on a run whose positional scoping would otherwise
  skip `SmokeCollector.collect()` (the only other caller) entirely
- Stop `_validate_smoke_options` from rejecting `--airflow-smoke-update`
  combined with sampling when `test_dag_serialization_snapshot`, the
  only item that guard protects, is itself disabled
- Make `ScheduleSanityItem` report a Dag's serialization failure itself
  once `test_dag_serialization_roundtrip` (its usual reporter) is
  disabled, instead of silently passing a Dag the scheduler can't
  serialize
- Extract `_select_serialization_sample`, deduplicating the
  sample/seed/select/log block shared by `_build_smoke_corpus` and
  `_serialized_dag_cache`
- Resolve `_get_dag_serializer()` lazily in `_build_smoke_corpus`, only
  once a Dag is actually selected for serialization

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* Cookbook: show what a dagbag + callable test misses (#172)

* Cookbook: show what a dagbag + callable test misses

Changes:
- Add a "What a dagbag + callable test misses" section to `docs/guide/cookbook.md`, built
  around one realistic multi-task `ingest` Dag: task relations (trigger rules, branching,
  cross-task xcom), asset-triggered cross-Dag relations, depends-on-past/backfill DagRun
  sequences, and retry behavior (`up_for_retry`, `try_number`)
- Demote the existing five recipes to a new `Community recipes` heading so the two framings
  don't blur together
- Back all four new recipes with real, passing tests in `tests/enduser/test_cookbook_ingest.py`
  and `test_cookbook_digest.py` (the latter genuinely 3.x-only, added to `conftest.py`'s
  `collect_ignore` alongside `test_assets.py`)
- Link the cookbook from README's `Why not...` section

Closes #165.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix review findings: 2.x-safe backfill recipe, correct db_test marker

Changes:
- Add `catchup=False` to the `ingest_backfill` Dag: without it, `PrevDagrunDep` takes a
  different branch on the Airflow 2.x family (`catchup_by_default` defaults `True` there),
  which never sees day one's manual run and passes the depends-on-past recipe for the wrong
  reason. Caught by the compat-matrix-aware adversarial review pass
- Drop `pytest.mark.db_test` from the module-level `pytestmark` in
  `test_cookbook_ingest.py` and apply it per-function to the three `dag_maker` tests only,
  matching every sibling `tests/enduser/` module -- it was wrongly forcing DB init for the
  DB-free `run_task`-based retry test, which exists to prove no DagRun is needed
- Drop the dead `ignore_ti_state=True` from the backfill rescue call; only
  `ignore_depends_on_past=True` does anything there
- Fix cookbook prose that overstated all four recipes running through the `ingest` Dag via
  `dag_maker` (the retry recipe's second half uses the DB-free `run_task` fixture on a
  throwaway Dag) and that implied the cross-Dag recipe's producer reuses recipe 1's `ingest`
  Dag (it's a separate, `ingest`-shaped Dag)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* Add render_task for DB-free template-field rendering (#174)

* Add render_task for DB-free template-field rendering

Changes:
- Add `_compat.in_process.render_task_in_process`, extracting shared
  `RuntimeTaskInstance` construction from `run_task_in_process` into
  `_build_runtime_task_instance` so both share it
- Add the `render_task` fixture (`fixtures/render.py`) and `RenderTask` protocol,
  gated on the Airflow 2.x family the same way as `run_task`
- Add a `rendered(...)` matcher to `matchers.py` for one-expression assertions;
  it must be the left operand since `BaseOperator.__eq__` returns `False` instead
  of `NotImplemented` for foreign types
- Deliberately skip `task.prepare_for_execution()` before rendering, unlike the
  real run path, so the resolved fields land on the caller's own operator object
- Document `render_task` in `db-free-execution.md`, cross-link it from the
  cookbook's rendered-templates recipe, and list it alongside `run_task` in the
  2.x-gated fixture set in `README.md`/`docs/index.md`/`AGENTS.md`

Closes #118.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Render onto a copy so render_task never mutates the caller's operator

Changes:
- Render onto a `prepare_for_execution()` copy (mirroring `task_runner.run`'s own
  preparation step) instead of the caller's own operator object -- a shared
  operator (module-level Dag, session-scoped fixture) previously went stale
  after the first `render_task` call, since a second render silently no-oped
  on a field that already held a plain string with no Jinja markup left
- Extract `_installed_supervisor_comms`, a context manager shared by
  `run_task_in_process` and `render_task_in_process`, replacing the duplicated
  SUPERVISOR_COMMS save/swap/restore `try`/`finally` block
- Return a `_RuntimeTaskInstanceBuild` NamedTuple from `_build_runtime_task_instance`
  instead of a bare `tuple[Any, Any]`
- Add `ValueError: ... try_number is less than 1` to the `Raises:` sections that
  omitted it
- Add a mapped-operator render test (`tests/compat` and `tests/enduser`, so it
  runs across the whole compat matrix): a mapped operator's `render_task` result
  is the concrete unmapped instance for `map_index`, never the mapped operator
  itself -- document this instead of asserting identity
- Fix the `db-free-execution.md` example, which built an operator with no bound
  Dag and would crash; bind it and assert against the return value
- Add a `test_matchers.py` case pinning the documented `rendered(...)`
  left-operand requirement against a fake operator with the same hostile
  `__eq__` shape as `BaseOperator`

Found by the adversarial review round: two independent passes flagged the
in-place mutation as a footgun from different angles (cross-test contamination,
mapped-operator identity), and `/code-review high` converged with both on the
missing `Raises:` line. The unbound-task-with-explicit-dag_id crash in
`_build_runtime_task_instance` (shared with `run_task_in_process`) is a
pre-existing gap, not a regression -- left alone, out of scope for #118.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants