prefect-client 2.17.0__py3-none-any.whl → 2.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prefect/_internal/compatibility/deprecated.py +2 -0
- prefect/_internal/pydantic/_compat.py +1 -0
- prefect/_internal/pydantic/utilities/field_validator.py +25 -10
- prefect/_internal/pydantic/utilities/model_dump.py +1 -1
- prefect/_internal/pydantic/utilities/model_validate.py +1 -1
- prefect/_internal/pydantic/utilities/model_validator.py +11 -3
- prefect/_internal/schemas/validators.py +0 -6
- prefect/_version.py +97 -38
- prefect/blocks/abstract.py +34 -1
- prefect/blocks/notifications.py +14 -5
- prefect/client/base.py +10 -5
- prefect/client/orchestration.py +125 -66
- prefect/client/schemas/actions.py +4 -3
- prefect/client/schemas/objects.py +6 -5
- prefect/client/schemas/schedules.py +2 -6
- prefect/deployments/__init__.py +0 -2
- prefect/deployments/base.py +2 -144
- prefect/deployments/deployments.py +2 -2
- prefect/deployments/runner.py +2 -2
- prefect/deployments/steps/core.py +3 -3
- prefect/deprecated/packaging/serializers.py +5 -4
- prefect/events/__init__.py +45 -0
- prefect/events/actions.py +250 -19
- prefect/events/cli/__init__.py +0 -0
- prefect/events/cli/automations.py +163 -0
- prefect/events/clients.py +133 -7
- prefect/events/schemas/automations.py +76 -3
- prefect/events/schemas/deployment_triggers.py +17 -59
- prefect/events/utilities.py +2 -0
- prefect/events/worker.py +12 -2
- prefect/exceptions.py +1 -1
- prefect/logging/__init__.py +2 -2
- prefect/logging/loggers.py +64 -1
- prefect/results.py +29 -10
- prefect/serializers.py +62 -31
- prefect/settings.py +6 -10
- prefect/types/__init__.py +90 -0
- prefect/utilities/pydantic.py +34 -15
- prefect/utilities/schema_tools/hydration.py +88 -19
- prefect/variables.py +4 -4
- {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/METADATA +1 -1
- {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/RECORD +45 -42
- {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/LICENSE +0 -0
- {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/WHEEL +0 -0
- {prefect_client-2.17.0.dist-info → prefect_client-2.18.0.dist-info}/top_level.txt +0 -0
prefect/events/actions.py
CHANGED
@@ -1,36 +1,70 @@
|
|
1
|
+
import abc
|
1
2
|
from typing import Any, Dict, Optional, Union
|
2
3
|
from uuid import UUID
|
3
4
|
|
5
|
+
from typing_extensions import Literal, TypeAlias
|
6
|
+
|
4
7
|
from prefect._internal.pydantic import HAS_PYDANTIC_V2
|
5
8
|
|
6
9
|
if HAS_PYDANTIC_V2:
|
7
|
-
from pydantic.v1 import Field
|
10
|
+
from pydantic.v1 import Field, root_validator
|
8
11
|
else:
|
9
|
-
from pydantic import Field
|
10
|
-
|
11
|
-
from typing_extensions import Literal
|
12
|
-
|
12
|
+
from pydantic import Field, root_validator
|
13
13
|
from prefect._internal.schemas.bases import PrefectBaseModel
|
14
|
+
from prefect.client.schemas.objects import StateType
|
14
15
|
|
15
16
|
|
16
|
-
class Action(PrefectBaseModel):
|
17
|
+
class Action(PrefectBaseModel, abc.ABC):
|
17
18
|
"""An Action that may be performed when an Automation is triggered"""
|
18
19
|
|
19
20
|
type: str
|
20
21
|
|
22
|
+
def describe_for_cli(self) -> str:
|
23
|
+
"""A human-readable description of the action"""
|
24
|
+
return self.type.replace("-", " ").capitalize()
|
25
|
+
|
21
26
|
|
22
27
|
class DoNothing(Action):
|
23
|
-
"""Do nothing
|
28
|
+
"""Do nothing when an Automation is triggered"""
|
24
29
|
|
25
30
|
type: Literal["do-nothing"] = "do-nothing"
|
26
31
|
|
27
32
|
|
28
|
-
class
|
29
|
-
"""
|
33
|
+
class DeploymentAction(Action):
|
34
|
+
"""Base class for Actions that operate on Deployments and need to infer them from
|
35
|
+
events"""
|
36
|
+
|
37
|
+
source: Literal["selected", "inferred"] = Field(
|
38
|
+
"selected",
|
39
|
+
description=(
|
40
|
+
"Whether this Action applies to a specific selected "
|
41
|
+
"deployment (given by `deployment_id`), or to a deployment that is "
|
42
|
+
"inferred from the triggering event. If the source is 'inferred', "
|
43
|
+
"the `deployment_id` may not be set. If the source is 'selected', the "
|
44
|
+
"`deployment_id` must be set."
|
45
|
+
),
|
46
|
+
)
|
47
|
+
deployment_id: Optional[UUID] = Field(
|
48
|
+
None, description="The identifier of the deployment"
|
49
|
+
)
|
50
|
+
|
51
|
+
@root_validator
|
52
|
+
def selected_deployment_requires_id(cls, values):
|
53
|
+
wants_selected_deployment = values.get("source") == "selected"
|
54
|
+
has_deployment_id = bool(values.get("deployment_id"))
|
55
|
+
if wants_selected_deployment != has_deployment_id:
|
56
|
+
raise ValueError(
|
57
|
+
"deployment_id is "
|
58
|
+
+ ("not allowed" if has_deployment_id else "required")
|
59
|
+
)
|
60
|
+
return values
|
61
|
+
|
62
|
+
|
63
|
+
class RunDeployment(DeploymentAction):
|
64
|
+
"""Runs the given deployment with the given parameters"""
|
30
65
|
|
31
66
|
type: Literal["run-deployment"] = "run-deployment"
|
32
67
|
|
33
|
-
source: Literal["selected"] = "selected"
|
34
68
|
parameters: Optional[Dict[str, Any]] = Field(
|
35
69
|
None,
|
36
70
|
description=(
|
@@ -38,26 +72,223 @@ class RunDeployment(Action):
|
|
38
72
|
"deployment's default parameters"
|
39
73
|
),
|
40
74
|
)
|
41
|
-
deployment_id: UUID = Field(..., description="The identifier of the deployment")
|
42
75
|
job_variables: Optional[Dict[str, Any]] = Field(
|
43
76
|
None,
|
44
77
|
description=(
|
45
|
-
"
|
46
|
-
"deployment's default job variables"
|
78
|
+
"The job variables to pass to the created flow run, or None "
|
79
|
+
"to use the deployment's default job variables"
|
47
80
|
),
|
48
81
|
)
|
49
82
|
|
50
83
|
|
84
|
+
class PauseDeployment(DeploymentAction):
|
85
|
+
"""Pauses the given Deployment"""
|
86
|
+
|
87
|
+
type: Literal["pause-deployment"] = "pause-deployment"
|
88
|
+
|
89
|
+
|
90
|
+
class ResumeDeployment(DeploymentAction):
|
91
|
+
"""Resumes the given Deployment"""
|
92
|
+
|
93
|
+
type: Literal["resume-deployment"] = "resume-deployment"
|
94
|
+
|
95
|
+
|
96
|
+
class ChangeFlowRunState(Action):
|
97
|
+
"""Changes the state of a flow run associated with the trigger"""
|
98
|
+
|
99
|
+
type: Literal["change-flow-run-state"] = "change-flow-run-state"
|
100
|
+
|
101
|
+
name: Optional[str] = Field(
|
102
|
+
None,
|
103
|
+
description="The name of the state to change the flow run to",
|
104
|
+
)
|
105
|
+
state: StateType = Field(
|
106
|
+
...,
|
107
|
+
description="The type of the state to change the flow run to",
|
108
|
+
)
|
109
|
+
message: Optional[str] = Field(
|
110
|
+
None,
|
111
|
+
description="An optional message to associate with the state change",
|
112
|
+
)
|
113
|
+
|
114
|
+
|
115
|
+
class CancelFlowRun(Action):
|
116
|
+
"""Cancels a flow run associated with the trigger"""
|
117
|
+
|
118
|
+
type: Literal["cancel-flow-run"] = "cancel-flow-run"
|
119
|
+
|
120
|
+
|
121
|
+
class SuspendFlowRun(Action):
|
122
|
+
"""Suspends a flow run associated with the trigger"""
|
123
|
+
|
124
|
+
type: Literal["suspend-flow-run"] = "suspend-flow-run"
|
125
|
+
|
126
|
+
|
127
|
+
class CallWebhook(Action):
|
128
|
+
"""Call a webhook when an Automation is triggered."""
|
129
|
+
|
130
|
+
type: Literal["call-webhook"] = "call-webhook"
|
131
|
+
block_document_id: UUID = Field(
|
132
|
+
description="The identifier of the webhook block to use"
|
133
|
+
)
|
134
|
+
payload: str = Field(
|
135
|
+
default="",
|
136
|
+
description="An optional templatable payload to send when calling the webhook.",
|
137
|
+
)
|
138
|
+
|
139
|
+
|
51
140
|
class SendNotification(Action):
|
52
|
-
"""Send a notification
|
141
|
+
"""Send a notification when an Automation is triggered"""
|
53
142
|
|
54
143
|
type: Literal["send-notification"] = "send-notification"
|
55
|
-
|
56
144
|
block_document_id: UUID = Field(
|
57
|
-
|
145
|
+
description="The identifier of the notification block to use"
|
58
146
|
)
|
59
|
-
|
60
|
-
|
147
|
+
subject: str = Field("Prefect automated notification")
|
148
|
+
body: str = Field(description="The text of the notification to send")
|
149
|
+
|
150
|
+
|
151
|
+
class WorkPoolAction(Action):
|
152
|
+
"""Base class for Actions that operate on Work Pools and need to infer them from
|
153
|
+
events"""
|
154
|
+
|
155
|
+
source: Literal["selected", "inferred"] = Field(
|
156
|
+
"selected",
|
157
|
+
description=(
|
158
|
+
"Whether this Action applies to a specific selected "
|
159
|
+
"work pool (given by `work_pool_id`), or to a work pool that is "
|
160
|
+
"inferred from the triggering event. If the source is 'inferred', "
|
161
|
+
"the `work_pool_id` may not be set. If the source is 'selected', the "
|
162
|
+
"`work_pool_id` must be set."
|
163
|
+
),
|
164
|
+
)
|
165
|
+
work_pool_id: Optional[UUID] = Field(
|
166
|
+
None,
|
167
|
+
description="The identifier of the work pool to pause",
|
168
|
+
)
|
169
|
+
|
170
|
+
|
171
|
+
class PauseWorkPool(WorkPoolAction):
|
172
|
+
"""Pauses a Work Pool"""
|
173
|
+
|
174
|
+
type: Literal["pause-work-pool"] = "pause-work-pool"
|
175
|
+
|
176
|
+
|
177
|
+
class ResumeWorkPool(WorkPoolAction):
|
178
|
+
"""Resumes a Work Pool"""
|
179
|
+
|
180
|
+
type: Literal["resume-work-pool"] = "resume-work-pool"
|
181
|
+
|
182
|
+
|
183
|
+
class WorkQueueAction(Action):
|
184
|
+
"""Base class for Actions that operate on Work Queues and need to infer them from
|
185
|
+
events"""
|
186
|
+
|
187
|
+
source: Literal["selected", "inferred"] = Field(
|
188
|
+
"selected",
|
189
|
+
description=(
|
190
|
+
"Whether this Action applies to a specific selected "
|
191
|
+
"work queue (given by `work_queue_id`), or to a work queue that is "
|
192
|
+
"inferred from the triggering event. If the source is 'inferred', "
|
193
|
+
"the `work_queue_id` may not be set. If the source is 'selected', the "
|
194
|
+
"`work_queue_id` must be set."
|
195
|
+
),
|
196
|
+
)
|
197
|
+
work_queue_id: Optional[UUID] = Field(
|
198
|
+
None, description="The identifier of the work queue to pause"
|
199
|
+
)
|
200
|
+
|
201
|
+
@root_validator
|
202
|
+
def selected_work_queue_requires_id(cls, values):
|
203
|
+
wants_selected_work_queue = values.get("source") == "selected"
|
204
|
+
has_work_queue_id = bool(values.get("work_queue_id"))
|
205
|
+
if wants_selected_work_queue != has_work_queue_id:
|
206
|
+
raise ValueError(
|
207
|
+
"work_queue_id is "
|
208
|
+
+ ("not allowed" if has_work_queue_id else "required")
|
209
|
+
)
|
210
|
+
return values
|
211
|
+
|
212
|
+
|
213
|
+
class PauseWorkQueue(WorkQueueAction):
|
214
|
+
"""Pauses a Work Queue"""
|
215
|
+
|
216
|
+
type: Literal["pause-work-queue"] = "pause-work-queue"
|
217
|
+
|
218
|
+
|
219
|
+
class ResumeWorkQueue(WorkQueueAction):
|
220
|
+
"""Resumes a Work Queue"""
|
221
|
+
|
222
|
+
type: Literal["resume-work-queue"] = "resume-work-queue"
|
223
|
+
|
224
|
+
|
225
|
+
class AutomationAction(Action):
|
226
|
+
"""Base class for Actions that operate on Automations and need to infer them from
|
227
|
+
events"""
|
228
|
+
|
229
|
+
source: Literal["selected", "inferred"] = Field(
|
230
|
+
"selected",
|
231
|
+
description=(
|
232
|
+
"Whether this Action applies to a specific selected "
|
233
|
+
"automation (given by `automation_id`), or to an automation that is "
|
234
|
+
"inferred from the triggering event. If the source is 'inferred', "
|
235
|
+
"the `automation_id` may not be set. If the source is 'selected', the "
|
236
|
+
"`automation_id` must be set."
|
237
|
+
),
|
238
|
+
)
|
239
|
+
automation_id: Optional[UUID] = Field(
|
240
|
+
None, description="The identifier of the automation to act on"
|
241
|
+
)
|
242
|
+
|
243
|
+
@root_validator
|
244
|
+
def selected_automation_requires_id(cls, values):
|
245
|
+
wants_selected_automation = values.get("source") == "selected"
|
246
|
+
has_automation_id = bool(values.get("automation_id"))
|
247
|
+
if wants_selected_automation != has_automation_id:
|
248
|
+
raise ValueError(
|
249
|
+
"automation_id is "
|
250
|
+
+ ("not allowed" if has_automation_id else "required")
|
251
|
+
)
|
252
|
+
return values
|
253
|
+
|
254
|
+
|
255
|
+
class PauseAutomation(AutomationAction):
|
256
|
+
"""Pauses a Work Queue"""
|
257
|
+
|
258
|
+
type: Literal["pause-automation"] = "pause-automation"
|
259
|
+
|
260
|
+
|
261
|
+
class ResumeAutomation(AutomationAction):
|
262
|
+
"""Resumes a Work Queue"""
|
263
|
+
|
264
|
+
type: Literal["resume-automation"] = "resume-automation"
|
265
|
+
|
266
|
+
|
267
|
+
class DeclareIncident(Action):
|
268
|
+
"""Declares an incident for the triggering event. Only available on Prefect Cloud"""
|
269
|
+
|
270
|
+
type: Literal["declare-incident"] = "declare-incident"
|
61
271
|
|
62
272
|
|
63
|
-
|
273
|
+
# The actual action types that we support. It's important to update this
|
274
|
+
# Union when adding new subclasses of Action so that they are available for clients
|
275
|
+
# and in the OpenAPI docs
|
276
|
+
ActionTypes: TypeAlias = Union[
|
277
|
+
DoNothing,
|
278
|
+
RunDeployment,
|
279
|
+
PauseDeployment,
|
280
|
+
ResumeDeployment,
|
281
|
+
CancelFlowRun,
|
282
|
+
ChangeFlowRunState,
|
283
|
+
PauseWorkQueue,
|
284
|
+
ResumeWorkQueue,
|
285
|
+
SendNotification,
|
286
|
+
CallWebhook,
|
287
|
+
PauseAutomation,
|
288
|
+
ResumeAutomation,
|
289
|
+
SuspendFlowRun,
|
290
|
+
PauseWorkPool,
|
291
|
+
ResumeWorkPool,
|
292
|
+
# Prefect Cloud only
|
293
|
+
DeclareIncident,
|
294
|
+
]
|
File without changes
|
@@ -0,0 +1,163 @@
|
|
1
|
+
"""
|
2
|
+
Command line interface for working with automations.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import functools
|
6
|
+
|
7
|
+
import orjson
|
8
|
+
import yaml as pyyaml
|
9
|
+
from rich.pretty import Pretty
|
10
|
+
from rich.table import Table
|
11
|
+
from rich.text import Text
|
12
|
+
|
13
|
+
from prefect.cli._types import PrefectTyper
|
14
|
+
from prefect.cli._utilities import exit_with_error, exit_with_success
|
15
|
+
from prefect.cli.root import app
|
16
|
+
from prefect.client.orchestration import get_client
|
17
|
+
|
18
|
+
automations_app = PrefectTyper(
|
19
|
+
name="automation",
|
20
|
+
help="Commands for managing automations.",
|
21
|
+
)
|
22
|
+
app.add_typer(automations_app, aliases=["automations"])
|
23
|
+
|
24
|
+
|
25
|
+
def requires_automations(func):
|
26
|
+
@functools.wraps(func)
|
27
|
+
async def wrapper(*args, **kwargs):
|
28
|
+
try:
|
29
|
+
return await func(*args, **kwargs)
|
30
|
+
except RuntimeError as exc:
|
31
|
+
if "Enable experimental" in str(exc):
|
32
|
+
exit_with_error(str(exc))
|
33
|
+
raise
|
34
|
+
|
35
|
+
return wrapper
|
36
|
+
|
37
|
+
|
38
|
+
@automations_app.command()
|
39
|
+
@requires_automations
|
40
|
+
async def ls():
|
41
|
+
"""List all automations."""
|
42
|
+
async with get_client() as client:
|
43
|
+
automations = await client.read_automations()
|
44
|
+
|
45
|
+
table = Table(title="Automations", show_lines=True)
|
46
|
+
|
47
|
+
table.add_column("Automation")
|
48
|
+
table.add_column("Enabled")
|
49
|
+
table.add_column("Trigger")
|
50
|
+
table.add_column("Actions")
|
51
|
+
|
52
|
+
for automation in automations:
|
53
|
+
identifier_column = Text()
|
54
|
+
|
55
|
+
identifier_column.append(automation.name, style="white bold")
|
56
|
+
identifier_column.append("\n")
|
57
|
+
|
58
|
+
identifier_column.append(str(automation.id), style="cyan")
|
59
|
+
identifier_column.append("\n")
|
60
|
+
|
61
|
+
if automation.description:
|
62
|
+
identifier_column.append(automation.description, style="white")
|
63
|
+
identifier_column.append("\n")
|
64
|
+
|
65
|
+
if automation.actions_on_trigger or automation.actions_on_resolve:
|
66
|
+
actions = (
|
67
|
+
[
|
68
|
+
f"(trigger) {action.describe_for_cli()}"
|
69
|
+
for action in automation.actions
|
70
|
+
]
|
71
|
+
+ [
|
72
|
+
f"(trigger) {action.describe_for_cli()}"
|
73
|
+
for action in automation.actions_on_trigger
|
74
|
+
]
|
75
|
+
+ [
|
76
|
+
f"(resolve) {action.describe_for_cli()}"
|
77
|
+
for action in automation.actions
|
78
|
+
]
|
79
|
+
+ [
|
80
|
+
f"(resolve) {action.describe_for_cli()}"
|
81
|
+
for action in automation.actions_on_resolve
|
82
|
+
]
|
83
|
+
)
|
84
|
+
else:
|
85
|
+
actions = [action.describe_for_cli() for action in automation.actions]
|
86
|
+
|
87
|
+
table.add_row(
|
88
|
+
identifier_column,
|
89
|
+
str(automation.enabled),
|
90
|
+
automation.trigger.describe_for_cli(),
|
91
|
+
"\n".join(actions),
|
92
|
+
)
|
93
|
+
|
94
|
+
app.console.print(table)
|
95
|
+
|
96
|
+
|
97
|
+
@automations_app.command()
|
98
|
+
@requires_automations
|
99
|
+
async def inspect(id_or_name: str, yaml: bool = False, json: bool = False):
|
100
|
+
"""Inspect an automation."""
|
101
|
+
async with get_client() as client:
|
102
|
+
automation = await client.find_automation(id_or_name)
|
103
|
+
if not automation:
|
104
|
+
exit_with_error(f"Automation {id_or_name!r} not found.")
|
105
|
+
|
106
|
+
if yaml:
|
107
|
+
app.console.print(
|
108
|
+
pyyaml.dump(automation.dict(json_compatible=True), sort_keys=False)
|
109
|
+
)
|
110
|
+
elif json:
|
111
|
+
app.console.print(
|
112
|
+
orjson.dumps(
|
113
|
+
automation.dict(json_compatible=True), option=orjson.OPT_INDENT_2
|
114
|
+
).decode()
|
115
|
+
)
|
116
|
+
else:
|
117
|
+
app.console.print(Pretty(automation))
|
118
|
+
|
119
|
+
|
120
|
+
@automations_app.command(aliases=["enable"])
|
121
|
+
@requires_automations
|
122
|
+
async def resume(id_or_name: str):
|
123
|
+
"""Resume an automation."""
|
124
|
+
async with get_client() as client:
|
125
|
+
automation = await client.find_automation(id_or_name)
|
126
|
+
if not automation:
|
127
|
+
exit_with_error(f"Automation {id_or_name!r} not found.")
|
128
|
+
|
129
|
+
async with get_client() as client:
|
130
|
+
await client.resume_automation(automation.id)
|
131
|
+
|
132
|
+
exit_with_success(f"Resumed automation {automation.name!r} ({automation.id})")
|
133
|
+
|
134
|
+
|
135
|
+
@automations_app.command(aliases=["disable"])
|
136
|
+
@requires_automations
|
137
|
+
async def pause(id_or_name: str):
|
138
|
+
"""Pause an automation."""
|
139
|
+
async with get_client() as client:
|
140
|
+
automation = await client.find_automation(id_or_name)
|
141
|
+
if not automation:
|
142
|
+
exit_with_error(f"Automation {id_or_name!r} not found.")
|
143
|
+
|
144
|
+
async with get_client() as client:
|
145
|
+
await client.pause_automation(automation.id)
|
146
|
+
|
147
|
+
exit_with_success(f"Paused automation {automation.name!r} ({automation.id})")
|
148
|
+
|
149
|
+
|
150
|
+
@automations_app.command()
|
151
|
+
@requires_automations
|
152
|
+
async def delete(id_or_name: str):
|
153
|
+
"""Delete an automation."""
|
154
|
+
async with get_client() as client:
|
155
|
+
automation = await client.find_automation(id_or_name)
|
156
|
+
|
157
|
+
if not automation:
|
158
|
+
exit_with_success(f"Automation {id_or_name!r} not found.")
|
159
|
+
|
160
|
+
async with get_client() as client:
|
161
|
+
await client.delete_automation(automation.id)
|
162
|
+
|
163
|
+
exit_with_success(f"Deleted automation {automation.name!r} ({automation.id})")
|
prefect/events/clients.py
CHANGED
@@ -14,6 +14,7 @@ from typing import (
|
|
14
14
|
)
|
15
15
|
from uuid import UUID
|
16
16
|
|
17
|
+
import httpx
|
17
18
|
import orjson
|
18
19
|
import pendulum
|
19
20
|
from cachetools import TTLCache
|
@@ -25,9 +26,15 @@ from websockets.exceptions import (
|
|
25
26
|
ConnectionClosedOK,
|
26
27
|
)
|
27
28
|
|
29
|
+
from prefect.client.base import PrefectHttpxClient
|
28
30
|
from prefect.events import Event
|
29
31
|
from prefect.logging import get_logger
|
30
|
-
from prefect.settings import
|
32
|
+
from prefect.settings import (
|
33
|
+
PREFECT_API_KEY,
|
34
|
+
PREFECT_API_URL,
|
35
|
+
PREFECT_CLOUD_API_URL,
|
36
|
+
PREFECT_EXPERIMENTAL_EVENTS,
|
37
|
+
)
|
31
38
|
|
32
39
|
if TYPE_CHECKING:
|
33
40
|
from prefect.events.filters import EventFilter
|
@@ -35,6 +42,53 @@ if TYPE_CHECKING:
|
|
35
42
|
logger = get_logger(__name__)
|
36
43
|
|
37
44
|
|
45
|
+
def get_events_client(
|
46
|
+
reconnection_attempts: int = 10,
|
47
|
+
checkpoint_every: int = 20,
|
48
|
+
) -> "EventsClient":
|
49
|
+
api_url = PREFECT_API_URL.value()
|
50
|
+
if isinstance(api_url, str) and api_url.startswith(PREFECT_CLOUD_API_URL.value()):
|
51
|
+
return PrefectCloudEventsClient(
|
52
|
+
reconnection_attempts=reconnection_attempts,
|
53
|
+
checkpoint_every=checkpoint_every,
|
54
|
+
)
|
55
|
+
elif PREFECT_EXPERIMENTAL_EVENTS:
|
56
|
+
if PREFECT_API_URL:
|
57
|
+
return PrefectEventsClient(
|
58
|
+
reconnection_attempts=reconnection_attempts,
|
59
|
+
checkpoint_every=checkpoint_every,
|
60
|
+
)
|
61
|
+
else:
|
62
|
+
return PrefectEphemeralEventsClient()
|
63
|
+
|
64
|
+
raise RuntimeError(
|
65
|
+
"The current server and client configuration does not support "
|
66
|
+
"events. Enable experimental events support with the "
|
67
|
+
"PREFECT_EXPERIMENTAL_EVENTS setting."
|
68
|
+
)
|
69
|
+
|
70
|
+
|
71
|
+
def get_events_subscriber(
|
72
|
+
filter: "EventFilter" = None,
|
73
|
+
reconnection_attempts: int = 10,
|
74
|
+
) -> "PrefectEventSubscriber":
|
75
|
+
api_url = PREFECT_API_URL.value()
|
76
|
+
if isinstance(api_url, str) and api_url.startswith(PREFECT_CLOUD_API_URL.value()):
|
77
|
+
return PrefectCloudEventSubscriber(
|
78
|
+
filter=filter, reconnection_attempts=reconnection_attempts
|
79
|
+
)
|
80
|
+
elif PREFECT_EXPERIMENTAL_EVENTS:
|
81
|
+
return PrefectEventSubscriber(
|
82
|
+
filter=filter, reconnection_attempts=reconnection_attempts
|
83
|
+
)
|
84
|
+
|
85
|
+
raise RuntimeError(
|
86
|
+
"The current server and client configuration does not support "
|
87
|
+
"events. Enable experimental events support with the "
|
88
|
+
"PREFECT_EXPERIMENTAL_EVENTS setting."
|
89
|
+
)
|
90
|
+
|
91
|
+
|
38
92
|
class EventsClient(abc.ABC):
|
39
93
|
"""The abstract interface for all Prefect Events clients"""
|
40
94
|
|
@@ -119,6 +173,52 @@ def _get_api_url_and_key(
|
|
119
173
|
return api_url, api_key
|
120
174
|
|
121
175
|
|
176
|
+
class PrefectEphemeralEventsClient(EventsClient):
|
177
|
+
"""A Prefect Events client that sends events to an ephemeral Prefect server"""
|
178
|
+
|
179
|
+
def __init__(self):
|
180
|
+
if not PREFECT_EXPERIMENTAL_EVENTS:
|
181
|
+
raise ValueError(
|
182
|
+
"PrefectEphemeralEventsClient can only be used when "
|
183
|
+
"PREFECT_EXPERIMENTAL_EVENTS is set to True"
|
184
|
+
)
|
185
|
+
if PREFECT_API_KEY.value():
|
186
|
+
raise ValueError(
|
187
|
+
"PrefectEphemeralEventsClient cannot be used when PREFECT_API_KEY is set."
|
188
|
+
" Please use PrefectEventsClient or PrefectCloudEventsClient instead."
|
189
|
+
)
|
190
|
+
from prefect.server.api.server import create_app
|
191
|
+
|
192
|
+
app = create_app()
|
193
|
+
|
194
|
+
self._http_client = PrefectHttpxClient(
|
195
|
+
transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
|
196
|
+
base_url="http://ephemeral-prefect/api",
|
197
|
+
enable_csrf_support=False,
|
198
|
+
)
|
199
|
+
|
200
|
+
async def __aenter__(self) -> Self:
|
201
|
+
await super().__aenter__()
|
202
|
+
await self._http_client.__aenter__()
|
203
|
+
return self
|
204
|
+
|
205
|
+
async def __aexit__(
|
206
|
+
self,
|
207
|
+
exc_type: Optional[Type[Exception]],
|
208
|
+
exc_val: Optional[Exception],
|
209
|
+
exc_tb: Optional[TracebackType],
|
210
|
+
) -> None:
|
211
|
+
self._websocket = None
|
212
|
+
await self._http_client.__aexit__(exc_type, exc_val, exc_tb)
|
213
|
+
return await super().__aexit__(exc_type, exc_val, exc_tb)
|
214
|
+
|
215
|
+
async def _emit(self, event: Event) -> None:
|
216
|
+
await self._http_client.post(
|
217
|
+
"/events",
|
218
|
+
json=[event.dict(json_compatible=True)],
|
219
|
+
)
|
220
|
+
|
221
|
+
|
122
222
|
class PrefectEventsClient(EventsClient):
|
123
223
|
"""A Prefect Events client that streams events to a Prefect server"""
|
124
224
|
|
@@ -274,18 +374,18 @@ SEEN_EVENTS_SIZE = 500_000
|
|
274
374
|
SEEN_EVENTS_TTL = 120
|
275
375
|
|
276
376
|
|
277
|
-
class
|
377
|
+
class PrefectEventSubscriber:
|
278
378
|
"""
|
279
379
|
Subscribes to a Prefect Cloud event stream, yielding events as they occur.
|
280
380
|
|
281
381
|
Example:
|
282
382
|
|
283
|
-
from prefect.events.clients import
|
383
|
+
from prefect.events.clients import PrefectEventSubscriber
|
284
384
|
from prefect.events.filters import EventFilter, EventNameFilter
|
285
385
|
|
286
386
|
filter = EventFilter(event=EventNameFilter(prefix=["prefect.flow-run."]))
|
287
387
|
|
288
|
-
async with
|
388
|
+
async with PrefectEventSubscriber(filter=filter) as subscriber:
|
289
389
|
async for event in subscriber:
|
290
390
|
print(event.occurred, event.resource.id, event.event)
|
291
391
|
|
@@ -298,7 +398,6 @@ class PrefectCloudEventSubscriber:
|
|
298
398
|
def __init__(
|
299
399
|
self,
|
300
400
|
api_url: str = None,
|
301
|
-
api_key: str = None,
|
302
401
|
filter: "EventFilter" = None,
|
303
402
|
reconnection_attempts: int = 10,
|
304
403
|
):
|
@@ -309,7 +408,9 @@ class PrefectCloudEventSubscriber:
|
|
309
408
|
reconnection_attempts: When the client is disconnected, how many times
|
310
409
|
the client should attempt to reconnect
|
311
410
|
"""
|
312
|
-
|
411
|
+
if not api_url:
|
412
|
+
api_url = PREFECT_API_URL.value()
|
413
|
+
self._api_key = None
|
313
414
|
|
314
415
|
from prefect.events.filters import EventFilter
|
315
416
|
|
@@ -324,7 +425,6 @@ class PrefectCloudEventSubscriber:
|
|
324
425
|
|
325
426
|
logger.debug("Connecting to %s", socket_url)
|
326
427
|
|
327
|
-
self._api_key = api_key
|
328
428
|
self._connect = connect(
|
329
429
|
socket_url,
|
330
430
|
subprotocols=["prefect"],
|
@@ -441,3 +541,29 @@ class PrefectCloudEventSubscriber:
|
|
441
541
|
# a standard load balancer timeout, but after that, just take a
|
442
542
|
# beat to let things come back around.
|
443
543
|
await asyncio.sleep(1)
|
544
|
+
|
545
|
+
|
546
|
+
class PrefectCloudEventSubscriber(PrefectEventSubscriber):
|
547
|
+
def __init__(
|
548
|
+
self,
|
549
|
+
api_url: str = None,
|
550
|
+
api_key: str = None,
|
551
|
+
filter: "EventFilter" = None,
|
552
|
+
reconnection_attempts: int = 10,
|
553
|
+
):
|
554
|
+
"""
|
555
|
+
Args:
|
556
|
+
api_url: The base URL for a Prefect Cloud workspace
|
557
|
+
api_key: The API of an actor with the manage_events scope
|
558
|
+
reconnection_attempts: When the client is disconnected, how many times
|
559
|
+
the client should attempt to reconnect
|
560
|
+
"""
|
561
|
+
api_url, api_key = _get_api_url_and_key(api_url, api_key)
|
562
|
+
|
563
|
+
super().__init__(
|
564
|
+
api_url=api_url,
|
565
|
+
filter=filter,
|
566
|
+
reconnection_attempts=reconnection_attempts,
|
567
|
+
)
|
568
|
+
|
569
|
+
self._api_key = api_key
|