Skip to content

Commit a99d899

Browse files
authored
Merge branch 'main' into fix/sentry-module-pkg-import
2 parents 73dba89 + 9cb3058 commit a99d899

244 files changed

Lines changed: 21637 additions & 1322 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish.yml

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -227,51 +227,47 @@ jobs:
227227
gh release upload "${{ github.ref_name }}" \
228228
"constraints-${VERSION}.txt" constraints.txt --clobber
229229
230+
discover-plugin-packages:
231+
name: Discover plugin packages
232+
runs-on: ubuntu-latest
233+
outputs:
234+
workdirs: ${{ steps.find.outputs.workdirs }}
235+
steps:
236+
- uses: actions/checkout@v7
237+
with:
238+
fetch-depth: "0"
239+
ref: ${{ inputs.tag || github.ref }}
240+
- name: Find every distribution that claims a flyteplugins name
241+
id: find
242+
run: |
243+
# Every directory whose pyproject.toml claims a flyteplugins name has reserved that
244+
# name on PyPI, so it has to be published from here. PyPI is first come first served
245+
# and there is no ownership of the flyteplugins prefix, so a name we declare but never
246+
# upload just sits unregistered for anyone to claim -- and because pip runs a source
247+
# distribution's build backend at install time, whoever claims it executes code
248+
# wherever our docs tell people to install it, including our own remote image builder.
249+
# This job used to be a hand-maintained list, which silently drifted from the plugins
250+
# actually in the tree. Discovering the set instead means adding a plugin cannot leave
251+
# its name unclaimed. maxdepth 3 covers plugins/<name> and plugins/agents/<name>.
252+
workdirs=$(find plugins -mindepth 1 -maxdepth 3 -name pyproject.toml -not -path '*/.venv/*' \
253+
-exec grep -lE '^name[[:space:]]*=[[:space:]]*"flyteplugins' {} + \
254+
| xargs -n1 dirname | sort | jq -R -s -c 'split("\n") | map(select(. != ""))')
255+
if [ "$(echo "$workdirs" | jq 'length')" -eq 0 ]; then
256+
echo "ERROR: discovered no plugin distributions; refusing to publish an empty set"
257+
exit 1
258+
fi
259+
echo "Discovered $(echo "$workdirs" | jq 'length') plugin distributions:"
260+
echo "$workdirs" | jq -r '.[]'
261+
echo "workdirs=$workdirs" >> "$GITHUB_OUTPUT"
262+
230263
plugin-pypi:
231264
name: PyPI package
265+
needs: discover-plugin-packages
232266
runs-on: ubuntu-latest
233267
strategy:
234268
fail-fast: false
235269
matrix:
236-
workdir:
237-
- "plugins/ray"
238-
- "plugins/spark"
239-
- "plugins/dask"
240-
- "plugins/pytorch"
241-
- "plugins/bigquery"
242-
- "plugins/databricks"
243-
- "plugins/snowflake"
244-
- "plugins/sglang"
245-
- "plugins/vllm"
246-
- "plugins/wandb"
247-
- "plugins/polars"
248-
- "plugins/codegen"
249-
- "plugins/hitl"
250-
- "plugins/jsonl"
251-
- "plugins/mlflow"
252-
- "plugins/papermill"
253-
- "plugins/pandera"
254-
- "plugins/hydra"
255-
- "plugins/omegaconf"
256-
- "plugins/huggingface"
257-
- "plugins/otel"
258-
- "plugins/agents/core"
259-
- "plugins/agents/openai"
260-
- "plugins/agents/claude"
261-
- "plugins/agents/mistral"
262-
- "plugins/agents/google"
263-
- "plugins/agents/crewai"
264-
- "plugins/agents/langchain"
265-
- "plugins/agents/deepagents"
266-
- "plugins/agents/langgraph"
267-
- "plugins/agents/pydantic_ai"
268-
- "plugins/agents/hermes"
269-
- "plugins/lance"
270-
include:
271-
- workdir: "plugins/sglang"
272-
image-type: sglang
273-
- workdir: "plugins/vllm"
274-
image-type: vllm
270+
workdir: ${{ fromJson(needs.discover-plugin-packages.outputs.workdirs) }}
275271
steps:
276272
- uses: actions/checkout@v7
277273
with:

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
>
44
> Read more in the announcement [here](https://www.union.ai/blog-post/flyte-2-is-generally-available-the-durable-open-source-ai-runtime).
55
>
6-
> **Want to try Flyte 2 locally?** Run the **[Devbox](https://www.union.ai/docs/v2/flyte/user-guide/get-started/run-modes/running-devbox/)**.
6+
> **Want to try Flyte 2 locally?** Run the **[Devbox locally](https://www.union.ai/docs/v2/flyte/user-guide/get-started/run-modes/running-devbox/)**,
7+
> or you can try it on Github Codespaces.
8+
>
9+
> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/flyteorg/flyte-devbox-codespace?quickstart=1)
710
811
---
912

@@ -18,6 +21,12 @@
1821
[![SDK Reference](https://img.shields.io/badge/SDK%20Reference-API-brightgreen)](https://www.union.ai/docs/v2/union/api-reference/flyte-sdk/)
1922
[![CLI Reference](https://img.shields.io/badge/CLI%20Reference-API-brightgreen)](https://www.union.ai/docs/v2/union/api-reference/flyte-cli/)
2023

24+
## 10 minutes to Flyte 2
25+
26+
The quickest way to get a feel for Flyte 2 is in this notebook:
27+
28+
[![Try In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/unionai/unionai-examples/blob/main/v2/user-guide/getting-started/ten_minutes_to_flyte.ipynb)
29+
2130
## Install
2231

2332
```bash
@@ -311,7 +320,8 @@ features = ["extension-module"]
311320
312321
## Learn More
313322
314-
- **[Try DevBox](https://www.union.ai/docs/v2/flyte/user-guide/get-started/run-modes/running-devbox/)** - Get started
323+
- **[Try DevBox Docs](https://www.union.ai/docs/v2/flyte/user-guide/get-started/run-modes/running-devbox/)** - Get started
324+
- **[Try Devbox in Github Codespaces](https://codespaces.new/flyteorg/flyte-devbox-codespace?quickstart=1)** - Get started in the browser
315325
- **[SDK Reference](https://www.union.ai/docs/v2/union/api-reference/flyte-sdk/)** — API reference docs
316326
- **[CLI Reference](https://www.union.ai/docs/v2/union/api-reference/flyte-cli/)** — CLI docs
317327
- **[Features](FEATURES.md)** — Async parallelism, app serving, tracing, and more
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Content-based caching for the *root* task of a run.
3+
4+
`hash_flyte_dataframe.py` shows content-based caching between tasks: a driver produces a
5+
DataFrame and calls a cached consumer twice, and the second call hits. This example covers the
6+
other entrypoint — passing a locally-built DataFrame straight into `flyte.run(...)`, so the
7+
cached task *is* the root action of the run.
8+
9+
The two paths compute the cache key differently. A sub-action's key is computed by the
10+
controller in-process, which substitutes `Literal.hash` for the literal's contents. A root
11+
action's key is derived from the offloaded inputs, which reference the upload URI — and every
12+
`flyte.run` uploads the DataFrame to a fresh URI. So without a content hash the second run
13+
misses even though the bytes are identical.
14+
15+
Passing `hash_method=` to `DataFrame.from_local_sync` makes both runs agree: the key follows
16+
the content, not where it happened to land in blob storage.
17+
18+
Run it twice; `check_cache_hit` below asserts the second run returns the first run's value.
19+
"""
20+
21+
import pandas as pd
22+
23+
import flyte
24+
from flyte import Cache
25+
from flyte.io import DataFrame, HashFunction
26+
27+
img = flyte.Image.from_debian_base(name="flyte-root-hash").with_pip_packages("pandas", "pyarrow")
28+
29+
env = flyte.TaskEnvironment(
30+
"flyte_root_action_hash",
31+
image=img,
32+
resources=flyte.Resources(cpu="1", memory="2Gi"),
33+
)
34+
35+
SAMPLE_DATA = {"id": [1, 2, 3, 4, 5], "value": [100, 200, 300, 400, 500]}
36+
37+
38+
def hash_pandas_dataframe(df: pd.DataFrame) -> str:
39+
"""Content-based hash: the same rows always produce the same digest."""
40+
return str(pd.util.hash_pandas_object(df).sum())
41+
42+
43+
@env.task(cache=Cache(behavior="override", version_override="v1"))
44+
async def main(df: DataFrame) -> str:
45+
"""Cached root task.
46+
47+
The random number is the cache probe: it is regenerated on every real execution, so two
48+
runs returning the same string can only mean the second one was served from the cache.
49+
"""
50+
import random
51+
52+
pdf = await df.open(pd.DataFrame).all()
53+
return f"rows={len(pdf)}, total={pdf['value'].sum()}, random={random.randint(1, 1000000)}"
54+
55+
56+
def build_input() -> DataFrame:
57+
"""The DataFrame to submit, tagged with a content-based hash.
58+
59+
Without `hash_method` the cache key would follow the (per-run, always new) upload URI and
60+
the second run would miss.
61+
"""
62+
return DataFrame.from_local_sync(
63+
pd.DataFrame(SAMPLE_DATA),
64+
hash_method=HashFunction.from_fn(hash_pandas_dataframe),
65+
)
66+
67+
68+
if __name__ == "__main__":
69+
flyte.init_from_config()
70+
71+
# Two independent submissions of the same content. Each uploads to its own URI.
72+
run1 = flyte.run(main, df=build_input())
73+
print(f"Run 1: {run1.url}")
74+
run1.wait()
75+
result1 = run1.outputs()[0]
76+
77+
run2 = flyte.run(main, df=build_input())
78+
print(f"Run 2: {run2.url}")
79+
run2.wait()
80+
result2 = run2.outputs()[0]
81+
82+
print(f"\nRun 1: {result1}")
83+
print(f"Run 2: {result2}")
84+
if result1 == result2:
85+
print("\n✓ Cache hit — the new upload URI did not change the cache key.")
86+
else:
87+
print("\n✗ Cache miss — the root action's key still tracks the upload URI.")

examples/advanced/local_tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def noio_task():
101101
@env.task
102102
async def parallel_main_no_io(q: str) -> int:
103103
print("Starting parallel_main_no_io", flush=True)
104-
noio_task()
104+
await noio_task.aio()
105105
await input_trace("hello world", "blah", 42)
106106
a = await output_trace()
107107
await noio_trace()
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Support a product this family does not ship a plugin for.
2+
3+
A provider is small: say which environment variable holds its secret, how to
4+
verify a delivery, and how to turn a payload into a `WebhookEvent`. Core does
5+
the rest — the app, the dashboard, dispatch, the scope allowlist, and idempotent
6+
launching.
7+
8+
Run it without an account:
9+
10+
python custom_provider.py --local
11+
12+
That posts a signed sample delivery through the app in-process, so you see
13+
verification, normalization, and dispatch end to end.
14+
15+
Once it works, move it into its own `flyteplugins-webhooks-<product>` package
16+
beside the others and add the one-line conformance test:
17+
18+
from flyte.extras.webhooks.testing import assert_provider_conforms
19+
import flyteplugins.webhooks.acme as plugin
20+
21+
def test_conformance():
22+
assert_provider_conforms(plugin)
23+
"""
24+
25+
import hashlib
26+
import hmac
27+
import json
28+
import os
29+
import sys
30+
from typing import ClassVar, Mapping
31+
32+
import flyte
33+
from flyte.extras.webhooks import (
34+
EventType,
35+
Provider,
36+
WebhookAppEnvironment,
37+
WebhookEvent,
38+
constant_time_equals,
39+
hex_hmac_sha256,
40+
json_body,
41+
lower_headers,
42+
)
43+
44+
45+
class Ticket(EventType):
46+
"""Acme's ticket events. `ANY` matches every action on the type."""
47+
48+
ANY = "ticket"
49+
OPENED = "ticket.opened"
50+
CLOSED = "ticket.closed"
51+
52+
53+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
54+
"""Verify Acme's hex HMAC-SHA256 over the raw body.
55+
56+
Use `constant_time_equals` rather than `hmac.compare_digest` directly: the
57+
latter raises `TypeError` on `str` operands containing non-ASCII, and this
58+
header comes off the wire, so a crafted one would turn a clean 401 into a
59+
500.
60+
"""
61+
signature = lower_headers(headers).get("x-acme-signature")
62+
if not signature:
63+
return False
64+
return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip())
65+
66+
67+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
68+
"""Normalize an Acme delivery.
69+
70+
Fill in `resource_id` and `occurred_at` wherever the product gives them:
71+
together they are the dedupe key, and without a timestamp every later change
72+
to one resource collapses onto the first one's key and never launches.
73+
"""
74+
payload = json_body(body)
75+
ticket = payload.get("ticket") or {}
76+
return WebhookEvent(
77+
provider="acme",
78+
event_type="ticket",
79+
action=payload.get("action"),
80+
delivery_id=str(payload.get("delivery_id") or ""),
81+
resource_id=str(ticket.get("id")) if ticket.get("id") is not None else None,
82+
occurred_at=ticket.get("updated_at"),
83+
scope=ticket.get("project"),
84+
title=ticket.get("subject"),
85+
url=ticket.get("url"),
86+
payload=payload,
87+
)
88+
89+
90+
class AcmeProvider(Provider):
91+
"""Acme's webhook provider, with its defaults pre-wired.
92+
93+
Users then write `providers=[AcmeProvider()]`. The app mounts
94+
`default_secret_env` for them; `secret_env=` is there for anyone storing the
95+
secret under a different name.
96+
"""
97+
98+
default_secret_env: ClassVar[str] = "ACME_WEBHOOK_SECRET"
99+
100+
def __init__(self, *, secret_env: str | None = None) -> None:
101+
super().__init__(
102+
name="acme",
103+
secret_env=secret_env or self.default_secret_env,
104+
verify=verify,
105+
parse=parse,
106+
setup_hint="Acme Settings -> Webhooks",
107+
)
108+
109+
110+
app_env = WebhookAppEnvironment(
111+
name="acme-webhooks",
112+
providers=[AcmeProvider()],
113+
image=flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("fastapi", "uvicorn"),
114+
)
115+
116+
117+
@app_env.on_event(Ticket.OPENED)
118+
async def on_ticket_opened(event):
119+
return {"saw": event.qualified_type, "resource": event.resource_id, "dedupe_key": event.dedupe_key()}
120+
121+
122+
#: A realistic delivery, the same thing a shipped plugin exports as SAMPLE_DELIVERY.
123+
SAMPLE_BODY = json.dumps(
124+
{
125+
"action": "opened",
126+
"delivery_id": "d-1",
127+
"ticket": {
128+
"id": 42,
129+
"subject": "Printer on fire",
130+
"project": "SUPPORT",
131+
"updated_at": "2024-01-01T00:00:00Z",
132+
"url": "https://acme.example/t/42",
133+
},
134+
}
135+
).encode()
136+
137+
138+
def _try_locally() -> None:
139+
from fastapi.testclient import TestClient
140+
141+
secret = os.environ.setdefault(AcmeProvider.default_secret_env, "local-trial-secret")
142+
headers = {"X-Acme-Signature": hmac.new(secret.encode(), SAMPLE_BODY, hashlib.sha256).hexdigest()}
143+
assert app_env.app is not None # built in __post_init__
144+
client = TestClient(app_env.app)
145+
146+
print("POST /webhook/acme (signed with a throwaway secret)")
147+
response = client.post("/webhook/acme", content=SAMPLE_BODY, headers=headers)
148+
print(f" {response.status_code} {response.json()}\n")
149+
150+
print("an unsigned delivery is refused:")
151+
bad = client.post("/webhook/acme", content=SAMPLE_BODY, headers={})
152+
print(f" {bad.status_code} {bad.json()}")
153+
154+
155+
if __name__ == "__main__":
156+
if "--local" in sys.argv:
157+
_try_locally()
158+
else:
159+
flyte.init_from_config()
160+
handle = flyte.serve(app_env)
161+
handle.activate(wait=True)
162+
print(f"Dashboard ready at {handle.endpoint}")

examples/basics/dir_download_sync_repro.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def download_directory_sync(d: Dir) -> list[str]:
4949
async def main() -> list[str]:
5050
remote_dir = await create_remote_directory()
5151
# Pass the Dir as an input to a sync task, which downloads it via download_sync().
52-
return download_directory_sync(d=remote_dir)
52+
# Sync tasks must be awaited via .aio() from an async parent — a blocking call would
53+
# stall the parent's event loop and now raises SyncTaskCallInAsyncContextError.
54+
return await download_directory_sync.aio(d=remote_dir)
5355

5456

5557
if __name__ == "__main__":

0 commit comments

Comments
 (0)