Skip to content

Commit 2bb6dda

Browse files
Merge branch 'main' into CLOUD-569
2 parents 5c05990 + 2b018ea commit 2bb6dda

Some content is hidden

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

75 files changed

+13292
-8219
lines changed

.codespellrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ skip = .git,*.pdf,*.svg,versioneer.py,package-lock.json,_vendor,*.css,.codespell
33
# from https://github.com/PrefectHQ/prefect/pull/10813#issuecomment-1732676130
44
ignore-regex = .*lazy=\"selectin\"|.*e import Bloc$|America/Nome
55

6-
ignore-words-list = selectin,aci,wqs,aks,ines,dependant,fsspec,automations,nmme
6+
ignore-words-list = selectin,aci,wqs,aks,ines,dependant,fsspec,automations,nmme,afterall
77

88
check-hidden = true

.github/workflows/ui-v2-checks.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,7 @@ jobs:
5454
- name: Build UI
5555
working-directory: ./ui-v2
5656
run: npm run build
57+
58+
- name: Run tests
59+
working-directory: ./ui-v2
60+
run: npm run test

docs/3.0/deploy/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Learn how to use deployments to trigger flow runs remotely.
66

77
Deployments allow you to run flows on a [schedule](/3.0/automate/add-schedules/) and trigger runs based on [events](/3.0/automate/events/automations-triggers/).
88

9-
[Deployments](/3.0/deploy/infrastructure-examples/docker/) are server-side representations of flows.
9+
Deployments are server-side representations of flows.
1010
They store the crucial metadata for remote orchestration including when, where, and how a workflow should run.
1111

1212
In addition to manually triggering and managing flow runs, deploying a flow exposes an API and UI that allow you to:

docs/3.0/develop/manage-states.mdx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,13 @@ import FinalFlowState from '/snippets/final-flow-state.mdx'
123123
State change hooks execute code in response to **_client side_** changes in flow or task run states, enabling you to define actions for
124124
specific state transitions in a workflow.
125125

126-
A state hook must have the following signature:
126+
State hooks have the following signature:
127127

128128
```python
129-
def my_hook(obj: Task | Flow, run: TaskRun | FlowRun, state: State) -> None:
129+
def my_task_state_hook(task: Task, run: TaskRun, state: State) -> None:
130+
...
131+
132+
def my_flow_state_hook(flow: Flow, run: FlowRun, state: State) -> None:
130133
...
131134
```
132135

@@ -137,7 +140,7 @@ from prefect import task, flow
137140

138141
# for type hints only
139142
from prefect import Task
140-
from prefect.context import TaskRun
143+
from prefect.client.schemas.objects import TaskRun
141144
from prefect.states import State
142145

143146

@@ -154,11 +157,17 @@ def nice_task(name: str):
154157

155158

156159
# alternatively hooks can be specified via decorator
157-
@my_nice_task.on_completion
160+
@nice_task.on_completion
158161
def second_hook(tsk: Task, run: TaskRun, state: State) -> None:
159162
print('another hook')
163+
164+
nice_task(name='Marvin')
160165
```
161166

167+
<Note>
168+
To import a `TaskRun` or `FlowRun` for type hinting, you can import from `prefect.client.schemas.objects`.
169+
</Note>
170+
162171
State change hooks are versatile, allowing you to specify multiple state change hooks for the same state transition,
163172
or to use the same state change hook for different transitions:
164173

docs/3.0/get-started/quickstart.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ You can also choose from other [work pool types](https://docs.prefect.io/concept
171171

172172
## Deploy and schedule your flow
173173

174-
A [deployment](/3.0/deploy/infrastructure-examples/docker/) is used to determine when, where, and how a flow should run.
174+
A [deployment](/3.0/deploy/) is used to determine when, where, and how a flow should run.
175175
Deployments elevate flows to remotely configurable entities that have their own API.
176176

177177
1. Create a deployment in code:

src/integrations/prefect-slack/prefect_slack/credentials.py

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Credential classes use to store Slack credentials."""
22

3-
from typing import Optional
3+
from typing import Any, Optional, Union
44

55
from pydantic import Field, SecretStr
66
from slack_sdk.web.async_client import AsyncWebClient
77
from slack_sdk.webhook.async_client import AsyncWebhookClient
8+
from slack_sdk.webhook.client import WebhookClient
89

10+
from prefect._internal.compatibility.async_dispatch import async_dispatch
911
from prefect.blocks.core import Block
1012
from prefect.blocks.notifications import NotificationBlock
11-
from prefect.utilities.asyncutils import sync_compatible
1213

1314

1415
class SlackCredentials(Block):
@@ -49,6 +50,14 @@ def get_client(self) -> AsyncWebClient:
4950
return AsyncWebClient(token=self.token.get_secret_value())
5051

5152

53+
async def _notify_async(obj: Any, body: str, subject: Optional[str] = None):
54+
client = obj.get_client()
55+
56+
response = await client.send(text=body)
57+
58+
obj._raise_on_failure(response)
59+
60+
5261
class SlackWebhook(NotificationBlock):
5362
"""
5463
Block holding a Slack webhook for use in tasks and flows.
@@ -90,22 +99,18 @@ class SlackWebhook(NotificationBlock):
9099
examples=["https://hooks.slack.com/XXX"],
91100
)
92101

93-
def get_client(self) -> AsyncWebhookClient:
102+
def get_client(
103+
self, sync_client: bool = False
104+
) -> Union[AsyncWebhookClient, WebhookClient]:
94105
"""
95106
Returns an authenticated `AsyncWebhookClient` to interact with the configured
96107
Slack webhook.
97108
"""
109+
if sync_client:
110+
return WebhookClient(url=self.url.get_secret_value())
98111
return AsyncWebhookClient(url=self.url.get_secret_value())
99112

100-
@sync_compatible
101-
async def notify(self, body: str, subject: Optional[str] = None):
102-
"""
103-
Sends a message to the Slack channel.
104-
"""
105-
client = self.get_client()
106-
107-
response = await client.send(text=body)
108-
113+
def _raise_on_failure(self, response: Any):
109114
# prefect>=2.17.2 added a means for notification blocks to raise errors on
110115
# failures. This is not available in older versions, so we need to check if the
111116
# private base class attribute exists before using it.
@@ -117,3 +122,20 @@ async def notify(self, body: str, subject: Optional[str] = None):
117122

118123
if response.status_code >= 400:
119124
raise NotificationError(f"Failed to send message: {response.body}")
125+
126+
async def notify_async(self, body: str, subject: Optional[str] = None):
127+
"""
128+
Sends a message to the Slack channel.
129+
"""
130+
await _notify_async(self, body, subject)
131+
132+
@async_dispatch(_notify_async) # type: ignore
133+
def notify(self, body: str, subject: Optional[str] = None):
134+
"""
135+
Sends a message to the Slack channel.
136+
"""
137+
client = self.get_client(sync_client=True)
138+
139+
response = client.send(text=body)
140+
141+
self._raise_on_failure(response)

src/integrations/prefect-slack/pyproject.toml

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,7 @@ classifiers = [
2222
"Programming Language :: Python :: 3.12",
2323
"Topic :: Software Development :: Libraries",
2424
]
25-
dependencies = [
26-
"aiohttp",
27-
"slack_sdk>=3.15.1",
28-
"prefect>=3.0.0rc1",
29-
]
25+
dependencies = ["aiohttp", "slack_sdk>=3.15.1", "prefect>=3.0.0rc1"]
3026
dynamic = ["version"]
3127

3228
[project.optional-dependencies]
@@ -74,7 +70,6 @@ fail_under = 80
7470
show_missing = true
7571

7672
[tool.pytest.ini_options]
73+
asyncio_default_fixture_loop_scope = "session"
7774
asyncio_mode = "auto"
78-
env = [
79-
"PREFECT_TEST_MODE=1",
80-
]
75+
env = ["PREFECT_TEST_MODE=1"]

src/integrations/prefect-slack/tests/test_credentials.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from unittest.mock import AsyncMock
1+
from unittest.mock import AsyncMock, MagicMock
22

33
import pytest
44
from prefect_slack import SlackCredentials, SlackWebhook
55
from slack_sdk.web.async_client import AsyncWebClient
6-
from slack_sdk.webhook.async_client import AsyncWebhookClient, WebhookResponse
6+
from slack_sdk.webhook.async_client import AsyncWebhookClient
7+
from slack_sdk.webhook.webhook_response import WebhookResponse
78

89

910
def test_slack_credentials():
@@ -63,3 +64,52 @@ async def test_slack_webhook_block_handles_raise_on_failure(
6364
with pytest.raises(NotificationError, match="Failed to send message: woops"):
6465
with block.raise_on_failure():
6566
await block.notify("hello", "world")
67+
68+
69+
def test_slack_webhook_sync_notify(monkeypatch):
70+
"""Test the sync notify path"""
71+
mock_client = MagicMock()
72+
mock_client.send.return_value = WebhookResponse(
73+
url="http://test", status_code=200, body="ok", headers={}
74+
)
75+
76+
webhook = SlackWebhook(url="http://test")
77+
monkeypatch.setattr(webhook, "get_client", MagicMock(return_value=mock_client))
78+
79+
webhook.notify("test message")
80+
mock_client.send.assert_called_once_with(text="test message")
81+
82+
83+
async def test_slack_webhook_async_notify(monkeypatch):
84+
"""Test the async notify path"""
85+
mock_client = MagicMock()
86+
mock_client.send = AsyncMock(
87+
return_value=WebhookResponse(
88+
url="http://test", status_code=200, body="ok", headers={}
89+
)
90+
)
91+
92+
webhook = SlackWebhook(url="http://test")
93+
monkeypatch.setattr(webhook, "get_client", MagicMock(return_value=mock_client))
94+
95+
await webhook.notify_async("test message")
96+
mock_client.send.assert_called_once_with(text="test message")
97+
98+
99+
@pytest.mark.parametrize("message", ["test message 1", "test message 2"])
100+
async def test_slack_webhook_notify_async_dispatch(monkeypatch, message):
101+
"""Test that async_dispatch properly handles both sync and async contexts"""
102+
103+
mock_response = WebhookResponse(
104+
url="http://test", status_code=200, body="ok", headers={}
105+
)
106+
107+
mock_client = MagicMock()
108+
mock_client.send = AsyncMock(return_value=mock_response)
109+
110+
webhook = SlackWebhook(url="http://test")
111+
monkeypatch.setattr(webhook, "get_client", lambda sync_client=False: mock_client)
112+
113+
# Test notification
114+
await webhook.notify(message)
115+
mock_client.send.assert_called_once_with(text=message)

src/prefect/client/orchestration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2613,7 +2613,7 @@ async def send_worker_heartbeat(
26132613
"heartbeat_interval_seconds": heartbeat_interval_seconds,
26142614
}
26152615
if worker_metadata:
2616-
params["worker_metadata"] = worker_metadata.model_dump(mode="json")
2616+
params["metadata"] = worker_metadata.model_dump(mode="json")
26172617
if get_worker_id:
26182618
params["return_id"] = get_worker_id
26192619

src/prefect/server/api/variables.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import List, Optional
66
from uuid import UUID
77

8+
import sqlalchemy as sa
89
from fastapi import Body, Depends, HTTPException, Path, status
910
from sqlalchemy.ext.asyncio import AsyncSession
1011

@@ -50,9 +51,15 @@ async def create_variable(
5051
db: PrefectDBInterface = Depends(provide_database_interface),
5152
) -> core.Variable:
5253
async with db.session_context(begin_transaction=True) as session:
53-
model = await models.variables.create_variable(
54-
session=session, variable=variable
55-
)
54+
try:
55+
model = await models.variables.create_variable(
56+
session=session, variable=variable
57+
)
58+
except sa.exc.IntegrityError:
59+
raise HTTPException(
60+
status_code=409,
61+
detail=f"A variable with the name {variable.name!r} already exists.",
62+
)
5663

5764
return core.Variable.model_validate(model, from_attributes=True)
5865

src/prefect/utilities/urls.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import ipaddress
33
import socket
44
import urllib.parse
5-
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
5+
from string import Formatter
6+
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union
67
from urllib.parse import urlparse
78
from uuid import UUID
89

@@ -22,7 +23,6 @@
2223

2324
# The following objects are excluded from UI URL generation because we lack a
2425
# directly-addressable URL:
25-
# worker
2626
# artifact
2727
# variable
2828
# saved-search
@@ -38,6 +38,7 @@
3838
"deployment": "deployments/deployment/{obj_id}",
3939
"automation": "automations/automation/{obj_id}",
4040
"received-event": "events/event/{occurred}/{obj_id}",
41+
"worker": "work-pools/work-pool/{work_pool_name}/worker/{obj_id}",
4142
}
4243

4344
# The following objects are excluded from API URL generation because we lack a
@@ -134,6 +135,7 @@ def url_for(
134135
obj_id: Optional[Union[str, UUID]] = None,
135136
url_type: URLType = "ui",
136137
default_base_url: Optional[str] = None,
138+
**additional_format_kwargs: Optional[Dict[str, Any]],
137139
) -> Optional[str]:
138140
"""
139141
Returns the URL for a Prefect object.
@@ -149,6 +151,8 @@ def url_for(
149151
Whether to return the URL for the UI (default) or API.
150152
default_base_url (str, optional):
151153
The default base URL to use if no URL is configured.
154+
additional_format_kwargs (Dict[str, Any], optional):
155+
Additional keyword arguments to pass to the URL format.
152156
153157
Returns:
154158
Optional[str]: The URL for the given object or None if the object is not supported.
@@ -246,7 +250,18 @@ def url_for(
246250
occurred=obj.occurred.strftime("%Y-%m-%d"), obj_id=obj_id
247251
)
248252
else:
249-
url = url_format.format(obj_id=obj_id)
253+
obj_keys = [
254+
fname
255+
for _, fname, _, _ in Formatter().parse(url_format)
256+
if fname is not None and fname != "obj_id"
257+
]
258+
259+
if not all(key in additional_format_kwargs for key in obj_keys):
260+
raise ValueError(
261+
f"Unable to generate URL for {name} because the following keys are missing: {', '.join(obj_keys)}"
262+
)
263+
264+
url = url_format.format(obj_id=obj_id, **additional_format_kwargs)
250265

251266
if not base_url.endswith("/"):
252267
base_url += "/"

src/prefect/workers/base.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -885,12 +885,18 @@ async def _submit_scheduled_flow_runs(
885885
get_current_settings().experiments.worker_logging_to_api_enabled
886886
and self.backend_id
887887
):
888-
worker_path = f"worker/{self.backend_id}"
889-
base_url = url_for("work-pool", self._work_pool.name)
888+
try:
889+
worker_url = url_for(
890+
"worker",
891+
obj_id=self.backend_id,
892+
work_pool_name=self._work_pool_name,
893+
)
890894

891-
run_logger.info(
892-
f"Running on worker id: {self.backend_id}. See worker logs here: {base_url}/{worker_path}"
893-
)
895+
run_logger.info(
896+
f"Running on worker id: {self.backend_id}. See worker logs here: {worker_url}"
897+
)
898+
except ValueError as ve:
899+
run_logger.warning(f"Failed to generate worker URL: {ve}")
894900

895901
self._submitting_flow_run_ids.add(flow_run.id)
896902
self._runs_task_group.start_soon(

tests/client/test_prefect_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2737,7 +2737,7 @@ async def test_worker_heartbeat_sends_metadata_if_passed(
27372737
assert mock_post.call_args[1]["json"] == {
27382738
"name": "test-worker",
27392739
"heartbeat_interval_seconds": 10,
2740-
"worker_metadata": {
2740+
"metadata": {
27412741
"integrations": [{"name": "prefect-aws", "version": "1.0.0"}]
27422742
},
27432743
}

0 commit comments

Comments
 (0)