Skip to content

Commit 660805a

Browse files
EngHabuclaude
andauthored
fix: accept list of triggers/links in @env.task decorator (#1037)
## Summary `@env.task(triggers=[trigger, ...])` previously surfaced as `DeploymentError: ... 'list' object has no attribute 'env_vars'` at deploy time. The decorator's coercion in `_task_environment.py` only checked for `tuple`, so a list was wrapped into a 1-tuple containing the list — and the inner list leaked through to `to_task_trigger`, which tried to read `.env_vars` off it. The user-facing docstring at `_trigger.py:724` uses `triggers=[my_trigger]` (list syntax), so users routinely hit this. ## Changes - `src/flyte/_task_environment.py:369-370` — coerce triggers by element type (`isinstance(triggers, Trigger)`) and links by container type (`Link` is a non-runtime-checkable Protocol, so we check for list/tuple). Both now correctly handle a single object, a list, or a tuple. - `tests/user_api/test_triggers.py` — added `test_task_with_triggers_as_list` and `test_task_with_single_element_list_trigger` covering the regression. ## Sentry Closes the deploy-time failure tracked in [FLYTE-SDK-17](https://unionai.sentry.io/share/issue/20054cd7e859428193c471b057c9a032/). ## Test plan - [x] `pytest tests/user_api/test_triggers.py` — 32 passed (30 existing + 2 new) - [x] `pytest tests/user_api/test_task_environment.py tests/user_api/test_triggers.py tests/flyte/test_trigger.py tests/flyte/internal/runtime/test_trigger_serde.py` — 93 passed - [ ] Manual: deploy a task with `triggers=[t1, t2]` against a real backend (no longer raises) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Haytham Abuelfutuh <haytham@afutuh.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1370d86 commit 660805a

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

src/flyte/_task_environment.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,8 @@ def decorator(func: F) -> AsyncFunctionTaskTemplate[P, R, F]:
366366
queue=queue or self.queue,
367367
interruptible=interruptible if interruptible is not None else self.interruptible,
368368
entrypoint=entrypoint,
369-
triggers=triggers if isinstance(triggers, tuple) else (triggers,),
370-
links=links if isinstance(links, tuple) else (links,),
369+
triggers=(triggers,) if isinstance(triggers, Trigger) else tuple(triggers),
370+
links=tuple(links) if isinstance(links, (list, tuple)) else (links,),
371371
task_resolver=task_resolver,
372372
)
373373
self._tasks[task_name] = tmpl

tests/user_api/test_triggers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,39 @@ async def task_with_triggers(trigger_time: datetime, x: int = 1) -> str:
180180
assert len(task_with_triggers.triggers) == 2
181181

182182

183+
def test_task_with_triggers_as_list():
184+
"""Triggers passed as a list must be normalized to a tuple of Trigger objects.
185+
186+
Regression: a list was being wrapped into a 1-tuple containing the list, which
187+
later surfaced as `'list' object has no attribute 'env_vars'` during deploy.
188+
"""
189+
env = flyte.TaskEnvironment(name="test_env")
190+
191+
trigger1 = flyte.Trigger.hourly(trigger_time_input_key="trigger_time")
192+
trigger2 = flyte.Trigger.daily(trigger_time_input_key="trigger_time")
193+
194+
@env.task(triggers=[trigger1, trigger2])
195+
async def task_with_list_triggers(trigger_time: datetime, x: int = 1) -> str:
196+
return f"Executed at {trigger_time.isoformat()}"
197+
198+
assert task_with_list_triggers.triggers == (trigger1, trigger2)
199+
assert all(isinstance(t, flyte.Trigger) for t in task_with_list_triggers.triggers)
200+
201+
202+
def test_task_with_single_element_list_trigger():
203+
"""A single-element list of triggers should not be wrapped twice."""
204+
env = flyte.TaskEnvironment(name="test_env")
205+
206+
trigger = flyte.Trigger.hourly(trigger_time_input_key="trigger_time")
207+
208+
@env.task(triggers=[trigger])
209+
async def task_single_list_trigger(trigger_time: datetime, x: int = 1) -> str:
210+
return f"Executed at {trigger_time.isoformat()}"
211+
212+
assert task_single_list_trigger.triggers == (trigger,)
213+
assert isinstance(task_single_list_trigger.triggers[0], flyte.Trigger)
214+
215+
183216
def test_task_with_trigger_defaults_no_overlap():
184217
"""Test task with defaults that don't overlap with trigger inputs"""
185218
env = flyte.TaskEnvironment(name="test_env")

0 commit comments

Comments
 (0)