Skip to content

Fix template file lookup for Dags packaged in zip files - #74542

Open
sjyangkevin wants to merge 4 commits into
apache:mainfrom
sjyangkevin:fix-zipped-dag-template-files
Open

sjyangkevin wants to merge 4 commits into
apache:mainfrom
sjyangkevin:fix-zipped-dag-template-files

Conversation

@sjyangkevin

@sjyangkevin sjyangkevin commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Why

Dags packaged as zip archives could not use template files shipped inside the archive. For a zipped Dag, dag.folder points into the archive (for example .../dags.zip), and Jinja's FileSystemLoader only reads real directories. A templated field such as "query": "test_sql/test.sql" therefore failed every run with:

jinja2.exceptions.TemplateNotFound: 'test_sql/test.sql' not found in search path: '.../test.zip'

At parse time the same lookup failed silently (only logged), and pointing template_searchpath into the archive failed the same way.

create_template_env() now reads search-path entries that are, or point inside, a zip archive directly from the archive. Normal directories keep exactly the same FileSystemLoader as before. Both template environment builders (DAG.get_template_env() and the operator-level render_template_as_native_obj override) go through this function.

closes: #59310
related: #60999

How zip entries are handled

  • The archive is found by walking up to the nearest existing path and checking it with zipfile.is_zipfile(), so detection does not depend on the .zip extension or its case.
  • Members are read in memory by exact name; nothing is extracted to disk.
  • Template names are validated with Jinja's split_template_path, as in FileSystemLoader, so they cannot leave the search root. Search-path prefixes containing .. are not treated as archive paths.
  • Search-path order is preserved across zip and directory entries, and a missing template still raises TemplateNotFound with the not found in search path(s): ... message.
  • An unreadable archive raises instead of falling through to a later search path.
  • The archive is opened per lookup, so no file handle is shared across the task supervisor's fork.

Testing

  • 18 new test cases. 9 fail without this change: loading from a zip (including DAGS.ZIP), zip-first ordering, both template environment builders with a .sql path nested in a dict, and parse-time resolve_template_files() on a Dag imported by the zip importer. The other 9 pass on main by design: they pin unchanged behaviour for non-zip search paths, the not-found message, and containment of template names. Each was checked to fail when the corresponding check in the new loader is removed.
  • 8 new test cases added.
  • End-to-end in Breeze, using airflow dags test and the UI from breeze start-airflow, with zipped Dags using a top-level template file, a .sql path nested in a dict, template_searchpath inside the archive, and {% include %}. All failed with TemplateNotFound before this change and succeed after it. Unzipped copies of the same Dags succeed both before and after.
  • The zip attached to the issue now gets past template rendering and reaches BigQueryInsertJobOperator.execute(). The BigQuery call itself was not run (no GCP credentials), and this change does not touch it.
  • prek pre-commit and manual stages pass, including mypy-task-sdk. From breeze verify: task-sdk-tests (3656 passed), core non-DB tests and core DB tests pass.
Before/after reproduction in Breeze (airflow dags test)

repro.zip contains repro_dags.py, scripts/hello.sh and sql/query.sql. The same files unzipped provide the dir_* control Dags. reporter.zip is the test.zip attached to the issue, unchanged.

Dag Template usage
zip_top_level bash_command="scripts/hello.sh"
zip_nested_dict .sql path nested in a dict field, like BigQueryInsertJobOperator
zip_searchpath template_searchpath pointing to a folder inside the archive
zip_include {% include 'scripts/hello.sh' %}
test_import_path_dag the Dag attached to the issue
dir_top_level unzipped control

Before (main), trimmed to each Dag's result and its own task error:

===== import errors
No data found
===== parse-time resolve_template_files warnings
Failed to resolve template field 'bash_command' [airflow.sdk.definitions._internal.templater]
######## dir_top_level -> state=success
hello from dir_top_level / t
######## zip_top_level -> state=failed
jinja2.exceptions.TemplateNotFound: 'scripts/hello.sh' not found in search path: '/files/repro_59310/dags/repro.zip'
######## zip_nested_dict -> state=failed
jinja2.exceptions.TemplateNotFound: 'sql/query.sql' not found in search path: '/files/repro_59310/dags/repro.zip'
######## zip_searchpath -> state=failed
jinja2.exceptions.TemplateNotFound: 'hello.sh' not found in search paths: '/files/repro_59310/dags/repro.zip', '/files/repro_59310/dags/repro.zip/scripts'
######## zip_include -> state=failed
jinja2.exceptions.TemplateNotFound: 'scripts/hello.sh' not found in search path: '/files/repro_59310/dags/repro.zip'
######## test_import_path_dag -> state=failed
jinja2.exceptions.TemplateNotFound: 'test_sql/test.sql' not found in search path: '/files/repro_59310/dags/reporter.zip'

After (this PR):

===== import errors
No data found
===== parse-time resolve_template_files warnings
######## dir_top_level -> state=success
hello from dir_top_level / t
######## zip_top_level -> state=success
hello from zip_top_level / t
######## zip_nested_dict -> state=success
RENDERED: {'query': {'query': 'SELECT column_a FROM test -- zip_nested_dict'}}
######## zip_searchpath -> state=success
hello from zip_searchpath / t
######## zip_include -> state=success
hello from zip_include / t
######## test_import_path_dag -> state=failed

test_import_path_dag now gets through template rendering and fails in BigQueryInsertJobOperator.execute() with AirflowNotFoundException: The conn_id `google_cloud_default` isn't defined, since no GCP connection.

repro_dags.py
import os

from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG, BaseOperator

# Same source is used zipped and unzipped; prefix keeps dag_ids unique in the bundle.
PREFIX = "zip" if ".zip" in __file__ else "dir"
HERE = os.path.dirname(__file__)


class NestedSqlOperator(BaseOperator):
    """Mimics BigQueryInsertJobOperator: a .sql path nested inside a dict template field."""

    template_fields = ("configuration",)
    template_ext = (".sql",)

    def __init__(self, configuration, **kwargs):
        super().__init__(**kwargs)
        self.configuration = configuration

    def execute(self, context):
        print("RENDERED:", self.configuration)
        return self.configuration["query"]["query"]


with DAG(dag_id=f"{PREFIX}_top_level", schedule=None):
    BashOperator(task_id="t", bash_command="scripts/hello.sh")

with DAG(dag_id=f"{PREFIX}_nested_dict", schedule=None):
    NestedSqlOperator(task_id="t", configuration={"query": {"query": "sql/query.sql"}})

with DAG(dag_id=f"{PREFIX}_searchpath", schedule=None, template_searchpath=[os.path.join(HERE, "scripts")]):
    BashOperator(task_id="t", bash_command="hello.sh")

with DAG(dag_id=f"{PREFIX}_include", schedule=None):
    BashOperator(task_id="t", bash_command="{% include 'scripts/hello.sh' %}")

scripts/hello.sh is echo "hello from {{ dag.dag_id }} / {{ ti.task_id }}" and sql/query.sql is SELECT column_a FROM test -- {{ dag.dag_id }}.


Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code (Opus 5.5) following the guidelines

@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch from 594297a to 849ba15 Compare October 10, 2026 21:04
@sjyangkevin
sjyangkevin marked this pull request as ready for review October 10, 2026 21:54
@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch from 849ba15 to 9976602 Compare October 11, 2026 11:23
return path


class TestCreateTemplateEnvZip:

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.

One test class per subject, not per area/feature we test.

i.e. move these to be in TestTemplater.

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 for pointing this out. The tests under this class have been cleanup, re-wrote, and moved under TestTemplater.

Comment on lines +370 to +379
def test_template_not_found_lists_search_paths(self, tmp_path):
archive = str(_write_zip(tmp_path / "dags.zip", {"query.sql": "SELECT 1"}))
directory = str(_write_template_dir(tmp_path / "templates", {}))
env = create_template_env(searchpath=[archive, directory])

with pytest.raises(jinja2.TemplateNotFound) as ctx:
env.get_template("missing.sql")

assert ctx.value.name == "missing.sql"
assert ctx.value.message == f"'missing.sql' not found in search paths: {archive!r}, {directory!r}"

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 is testing Jinja behaviour, not ours.

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.

After the above clean up, there are now two newly added test functions under TestTemplater.

  • test_loads_template_from_zip
  • test_loads_templates_from_zip_and_directory_search_paths

test_loads_template_from_zip invoke create_template_env which will go to the branch for building the zip loader in _build_template_loader. Then, it checks for 4 path patterns that the query can be rendered.

test_loads_templates_from_zip_and_directory_search_paths check two scenarios. A template in the zip is found, and a template in the folder is found, when both are specifying in searchpath.

Comment on lines +361 to +368
@pytest.mark.parametrize(("zip_first", "expected"), [(True, "from zip"), (False, "from directory")])
def test_searches_zip_and_directory_in_order(self, tmp_path, zip_first, expected):
archive = str(_write_zip(tmp_path / "dags.zip", {"query.sql": "from zip"}))
directory = str(_write_template_dir(tmp_path / "templates", {"query.sql": "from directory"}))

env = create_template_env(searchpath=[archive, directory] if zip_first else [directory, archive])

assert env.get_template("query.sql").render() == expected

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.

Also testing Jinja, not our code.

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.

the test had been cleanup.

Comment on lines +350 to +359
def test_keeps_filesystem_loader_without_zip(self, tmp_path, searchpath_parts):
(tmp_path / "templates").mkdir()
(tmp_path / "templates.zip").mkdir()
(tmp_path / "not_a_zip.zip").write_text("plain text")
searchpath = os.path.join(tmp_path, *searchpath_parts)

env = create_template_env(searchpath=[searchpath])

assert type(env.loader) is jinja2.FileSystemLoader
assert env.loader.searchpath == [searchpath]

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 test seems almost tautological. I can't see how this could ever fail?

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 for pointing this out. The test had been cleanup.

@ashb ashb left a comment

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.

Some work to be done on the Tests here, Agent added too many.

@developer-rpai developer-rpai 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.

Reviewed — reading templates straight out of the archive without extracting is the right approach, and opening the zip per lookup to avoid the fork offset issue is a nice catch. Tests cover the main shapes. LGTM.

@sjyangkevin
sjyangkevin requested a review from ashb October 13, 2026 12:44
@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch 3 times, most recently from a6a3900 to 680dd24 Compare October 16, 2026 18:22
@sjyangkevin

Copy link
Copy Markdown
Contributor Author

Hi @ashb , thank you for the feedback. I've re-structured the test, and removed those tautological tests. I wonder if I could get your help to have another look when you have time?

@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch 3 times, most recently from a78d265 to d91f8a8 Compare October 26, 2026 12:03
@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch from d91f8a8 to c42028a Compare November 1, 2026 23:25
A Dag imported from a zip archive has its folder inside the archive, which
Jinja's FileSystemLoader cannot read. Template files shipped next to the Dag
in the archive were therefore never found: parse-time resolution silently
kept the raw file path, and every task run failed with TemplateNotFound
before execute(). Pointing template_searchpath into the archive failed the
same way, leaving Dag authors with only a workaround outside the public
Task SDK interface.
A Dag in a subfolder of a zip archive that builds its template_searchpath
with os.path.join(os.path.dirname(__file__), "..", "sql") still could not
find its templates. Nothing resolves ".." inside an archive: the OS cannot
walk into the archive file, and archive members are matched by exact name,
so the path has to be normalized before it is split into the archive and
the folder inside it.

The tests are reduced to the cases that fail without the fix, as asked in
review. The removed ones also passed on main, because they checked
behaviour that comes from Jinja rather than from this change.
The template loader for zip search paths is kept instead of Jinja's
ChoiceLoader because its TemplateNotFound error lists the paths that were
searched, which is what a Dag author needs when a template is missing from
the archive. ChoiceLoader would only report the template name, so the
lookup test now also checks the error for a template found nowhere.
The error for a template found in no search path is the same as on main,
so checking it tested behaviour this change does not touch, which is what
the review pointed out. The test still checks that a zipped Dag loads
templates from both its archive and a regular template_searchpath folder.
@sjyangkevin
sjyangkevin force-pushed the fix-zipped-dag-template-files branch from c42028a to 6301f7f Compare November 6, 2026 01:59

This branch has not been deployed

No deployments
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.

Airflow can not open path in zipped DAG folder

3 participants