prefect-client 3.2.1__py3-none-any.whl → 3.2.3__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.
Files changed (72) hide show
  1. prefect/__init__.py +15 -8
  2. prefect/_build_info.py +5 -0
  3. prefect/_internal/schemas/bases.py +4 -7
  4. prefect/_internal/schemas/validators.py +5 -6
  5. prefect/_result_records.py +6 -1
  6. prefect/client/orchestration/__init__.py +18 -6
  7. prefect/client/schemas/schedules.py +2 -2
  8. prefect/concurrency/asyncio.py +4 -3
  9. prefect/concurrency/sync.py +3 -3
  10. prefect/concurrency/v1/asyncio.py +3 -3
  11. prefect/concurrency/v1/sync.py +3 -3
  12. prefect/deployments/flow_runs.py +2 -2
  13. prefect/docker/docker_image.py +2 -3
  14. prefect/engine.py +1 -1
  15. prefect/events/clients.py +4 -3
  16. prefect/events/related.py +3 -5
  17. prefect/flows.py +11 -5
  18. prefect/locking/filesystem.py +8 -8
  19. prefect/logging/handlers.py +7 -11
  20. prefect/main.py +0 -2
  21. prefect/runtime/flow_run.py +10 -17
  22. prefect/server/api/__init__.py +34 -0
  23. prefect/server/api/admin.py +85 -0
  24. prefect/server/api/artifacts.py +224 -0
  25. prefect/server/api/automations.py +239 -0
  26. prefect/server/api/block_capabilities.py +25 -0
  27. prefect/server/api/block_documents.py +164 -0
  28. prefect/server/api/block_schemas.py +153 -0
  29. prefect/server/api/block_types.py +211 -0
  30. prefect/server/api/clients.py +246 -0
  31. prefect/server/api/collections.py +75 -0
  32. prefect/server/api/concurrency_limits.py +286 -0
  33. prefect/server/api/concurrency_limits_v2.py +269 -0
  34. prefect/server/api/csrf_token.py +38 -0
  35. prefect/server/api/dependencies.py +196 -0
  36. prefect/server/api/deployments.py +941 -0
  37. prefect/server/api/events.py +300 -0
  38. prefect/server/api/flow_run_notification_policies.py +120 -0
  39. prefect/server/api/flow_run_states.py +52 -0
  40. prefect/server/api/flow_runs.py +867 -0
  41. prefect/server/api/flows.py +210 -0
  42. prefect/server/api/logs.py +43 -0
  43. prefect/server/api/middleware.py +73 -0
  44. prefect/server/api/root.py +35 -0
  45. prefect/server/api/run_history.py +170 -0
  46. prefect/server/api/saved_searches.py +99 -0
  47. prefect/server/api/server.py +891 -0
  48. prefect/server/api/task_run_states.py +52 -0
  49. prefect/server/api/task_runs.py +342 -0
  50. prefect/server/api/task_workers.py +31 -0
  51. prefect/server/api/templates.py +35 -0
  52. prefect/server/api/ui/__init__.py +3 -0
  53. prefect/server/api/ui/flow_runs.py +128 -0
  54. prefect/server/api/ui/flows.py +173 -0
  55. prefect/server/api/ui/schemas.py +63 -0
  56. prefect/server/api/ui/task_runs.py +175 -0
  57. prefect/server/api/validation.py +382 -0
  58. prefect/server/api/variables.py +181 -0
  59. prefect/server/api/work_queues.py +230 -0
  60. prefect/server/api/workers.py +656 -0
  61. prefect/settings/sources.py +18 -5
  62. prefect/states.py +3 -3
  63. prefect/task_engine.py +3 -3
  64. prefect/types/_datetime.py +82 -3
  65. prefect/utilities/dockerutils.py +2 -2
  66. prefect/workers/base.py +5 -5
  67. {prefect_client-3.2.1.dist-info → prefect_client-3.2.3.dist-info}/METADATA +10 -15
  68. {prefect_client-3.2.1.dist-info → prefect_client-3.2.3.dist-info}/RECORD +70 -32
  69. {prefect_client-3.2.1.dist-info → prefect_client-3.2.3.dist-info}/WHEEL +1 -2
  70. prefect/_version.py +0 -21
  71. prefect_client-3.2.1.dist-info/top_level.txt +0 -1
  72. {prefect_client-3.2.1.dist-info → prefect_client-3.2.3.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,269 @@
1
+ from typing import List, Literal, Optional, Union
2
+ from uuid import UUID
3
+
4
+ from fastapi import Body, Depends, HTTPException, Path, status
5
+
6
+ import prefect.server.models as models
7
+ import prefect.server.schemas as schemas
8
+ from prefect.server.api.dependencies import LimitBody
9
+ from prefect.server.database import PrefectDBInterface, provide_database_interface
10
+ from prefect.server.schemas import actions
11
+ from prefect.server.utilities.schemas import PrefectBaseModel
12
+ from prefect.server.utilities.server import PrefectRouter
13
+
14
+ router: PrefectRouter = PrefectRouter(
15
+ prefix="/v2/concurrency_limits", tags=["Concurrency Limits V2"]
16
+ )
17
+
18
+
19
+ @router.post("/", status_code=status.HTTP_201_CREATED)
20
+ async def create_concurrency_limit_v2(
21
+ concurrency_limit: actions.ConcurrencyLimitV2Create,
22
+ db: PrefectDBInterface = Depends(provide_database_interface),
23
+ ) -> schemas.core.ConcurrencyLimitV2:
24
+ async with db.session_context(begin_transaction=True) as session:
25
+ model = await models.concurrency_limits_v2.create_concurrency_limit(
26
+ session=session, concurrency_limit=concurrency_limit
27
+ )
28
+
29
+ return schemas.core.ConcurrencyLimitV2.model_validate(model)
30
+
31
+
32
+ @router.get("/{id_or_name}")
33
+ async def read_concurrency_limit_v2(
34
+ id_or_name: Union[UUID, str] = Path(
35
+ ..., description="The ID or name of the concurrency limit", alias="id_or_name"
36
+ ),
37
+ db: PrefectDBInterface = Depends(provide_database_interface),
38
+ ) -> schemas.responses.GlobalConcurrencyLimitResponse:
39
+ if isinstance(id_or_name, str): # TODO: this seems like it shouldn't be necessary
40
+ try:
41
+ id_or_name = UUID(id_or_name)
42
+ except ValueError:
43
+ pass
44
+ async with db.session_context() as session:
45
+ if isinstance(id_or_name, UUID):
46
+ model = await models.concurrency_limits_v2.read_concurrency_limit(
47
+ session, concurrency_limit_id=id_or_name
48
+ )
49
+ else:
50
+ model = await models.concurrency_limits_v2.read_concurrency_limit(
51
+ session, name=id_or_name
52
+ )
53
+
54
+ if not model:
55
+ raise HTTPException(
56
+ status_code=status.HTTP_404_NOT_FOUND, detail="Concurrency Limit not found"
57
+ )
58
+
59
+ return schemas.responses.GlobalConcurrencyLimitResponse.model_validate(model)
60
+
61
+
62
+ @router.post("/filter")
63
+ async def read_all_concurrency_limits_v2(
64
+ limit: int = LimitBody(),
65
+ offset: int = Body(0, ge=0),
66
+ db: PrefectDBInterface = Depends(provide_database_interface),
67
+ ) -> List[schemas.responses.GlobalConcurrencyLimitResponse]:
68
+ async with db.session_context() as session:
69
+ concurrency_limits = (
70
+ await models.concurrency_limits_v2.read_all_concurrency_limits(
71
+ session=session,
72
+ limit=limit,
73
+ offset=offset,
74
+ )
75
+ )
76
+
77
+ return [
78
+ schemas.responses.GlobalConcurrencyLimitResponse.model_validate(limit)
79
+ for limit in concurrency_limits
80
+ ]
81
+
82
+
83
+ @router.patch("/{id_or_name}", status_code=status.HTTP_204_NO_CONTENT)
84
+ async def update_concurrency_limit_v2(
85
+ concurrency_limit: actions.ConcurrencyLimitV2Update,
86
+ id_or_name: Union[UUID, str] = Path(
87
+ ..., description="The ID or name of the concurrency limit", alias="id_or_name"
88
+ ),
89
+ db: PrefectDBInterface = Depends(provide_database_interface),
90
+ ) -> None:
91
+ if isinstance(id_or_name, str): # TODO: this seems like it shouldn't be necessary
92
+ try:
93
+ id_or_name = UUID(id_or_name)
94
+ except ValueError:
95
+ pass
96
+ async with db.session_context(begin_transaction=True) as session:
97
+ if isinstance(id_or_name, UUID):
98
+ updated = await models.concurrency_limits_v2.update_concurrency_limit(
99
+ session,
100
+ concurrency_limit_id=id_or_name,
101
+ concurrency_limit=concurrency_limit,
102
+ )
103
+ else:
104
+ updated = await models.concurrency_limits_v2.update_concurrency_limit(
105
+ session, name=id_or_name, concurrency_limit=concurrency_limit
106
+ )
107
+
108
+ if not updated:
109
+ raise HTTPException(
110
+ status_code=status.HTTP_404_NOT_FOUND, detail="Concurrency Limit not found"
111
+ )
112
+
113
+
114
+ @router.delete("/{id_or_name}", status_code=status.HTTP_204_NO_CONTENT)
115
+ async def delete_concurrency_limit_v2(
116
+ id_or_name: Union[UUID, str] = Path(
117
+ ..., description="The ID or name of the concurrency limit", alias="id_or_name"
118
+ ),
119
+ db: PrefectDBInterface = Depends(provide_database_interface),
120
+ ) -> None:
121
+ if isinstance(id_or_name, str): # TODO: this seems like it shouldn't be necessary
122
+ try:
123
+ id_or_name = UUID(id_or_name)
124
+ except ValueError:
125
+ pass
126
+ async with db.session_context(begin_transaction=True) as session:
127
+ deleted = False
128
+ if isinstance(id_or_name, UUID):
129
+ deleted = await models.concurrency_limits_v2.delete_concurrency_limit(
130
+ session, concurrency_limit_id=id_or_name
131
+ )
132
+ else:
133
+ deleted = await models.concurrency_limits_v2.delete_concurrency_limit(
134
+ session, name=id_or_name
135
+ )
136
+
137
+ if not deleted:
138
+ raise HTTPException(
139
+ status_code=status.HTTP_404_NOT_FOUND, detail="Concurrency Limit not found"
140
+ )
141
+
142
+
143
+ class MinimalConcurrencyLimitResponse(PrefectBaseModel):
144
+ id: UUID
145
+ name: str
146
+ limit: int
147
+
148
+
149
+ @router.post("/increment", status_code=status.HTTP_200_OK)
150
+ async def bulk_increment_active_slots(
151
+ slots: int = Body(..., gt=0),
152
+ names: List[str] = Body(..., min_items=1),
153
+ mode: Literal["concurrency", "rate_limit"] = Body("concurrency"),
154
+ create_if_missing: Optional[bool] = Body(None),
155
+ db: PrefectDBInterface = Depends(provide_database_interface),
156
+ ) -> List[MinimalConcurrencyLimitResponse]:
157
+ async with db.session_context(begin_transaction=True) as session:
158
+ limits = [
159
+ schemas.core.ConcurrencyLimitV2.model_validate(limit)
160
+ for limit in (
161
+ await models.concurrency_limits_v2.bulk_read_or_create_concurrency_limits(
162
+ session=session, names=names, create_if_missing=create_if_missing
163
+ )
164
+ )
165
+ ]
166
+
167
+ active_limits = [limit for limit in limits if bool(limit.active)]
168
+
169
+ if any(limit.limit < slots for limit in active_limits):
170
+ raise HTTPException(
171
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
172
+ detail="Slots requested is greater than the limit",
173
+ )
174
+
175
+ non_decaying = [
176
+ str(limit.name)
177
+ for limit in active_limits
178
+ if limit.slot_decay_per_second == 0.0
179
+ ]
180
+
181
+ if mode == "rate_limit" and non_decaying:
182
+ raise HTTPException(
183
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
184
+ detail=(
185
+ "Only concurrency limits with slot decay can be used for "
186
+ "rate limiting. The following limits do not have a decay "
187
+ f"configured: {','.join(non_decaying)!r}"
188
+ ),
189
+ )
190
+ acquired = await models.concurrency_limits_v2.bulk_increment_active_slots(
191
+ session=session,
192
+ concurrency_limit_ids=[limit.id for limit in active_limits],
193
+ slots=slots,
194
+ )
195
+
196
+ if not acquired:
197
+ await session.rollback()
198
+
199
+ if acquired:
200
+ return [
201
+ MinimalConcurrencyLimitResponse(
202
+ id=limit.id, name=str(limit.name), limit=limit.limit
203
+ )
204
+ for limit in limits
205
+ ]
206
+ else:
207
+ async with db.session_context(begin_transaction=True) as session:
208
+ await models.concurrency_limits_v2.bulk_update_denied_slots(
209
+ session=session,
210
+ concurrency_limit_ids=[limit.id for limit in active_limits],
211
+ slots=slots,
212
+ )
213
+
214
+ def num_blocking_slots(limit: schemas.core.ConcurrencyLimitV2) -> float:
215
+ if limit.slot_decay_per_second > 0.0:
216
+ return slots + limit.denied_slots
217
+ else:
218
+ return (slots + limit.denied_slots) / limit.limit
219
+
220
+ blocking_limit = max((limit for limit in active_limits), key=num_blocking_slots)
221
+ blocking_slots = num_blocking_slots(blocking_limit)
222
+
223
+ wait_time_per_slot = (
224
+ blocking_limit.avg_slot_occupancy_seconds
225
+ if blocking_limit.slot_decay_per_second == 0.0
226
+ else (1.0 / blocking_limit.slot_decay_per_second)
227
+ )
228
+
229
+ retry_after = wait_time_per_slot * blocking_slots
230
+
231
+ raise HTTPException(
232
+ status_code=status.HTTP_423_LOCKED,
233
+ headers={
234
+ "Retry-After": str(retry_after),
235
+ },
236
+ )
237
+
238
+
239
+ @router.post("/decrement", status_code=status.HTTP_200_OK)
240
+ async def bulk_decrement_active_slots(
241
+ slots: int = Body(..., gt=0),
242
+ names: List[str] = Body(..., min_items=1),
243
+ occupancy_seconds: Optional[float] = Body(None, gt=0.0),
244
+ create_if_missing: bool = Body(True),
245
+ db: PrefectDBInterface = Depends(provide_database_interface),
246
+ ) -> List[MinimalConcurrencyLimitResponse]:
247
+ async with db.session_context(begin_transaction=True) as session:
248
+ limits = (
249
+ await models.concurrency_limits_v2.bulk_read_or_create_concurrency_limits(
250
+ session=session, names=names, create_if_missing=create_if_missing
251
+ )
252
+ )
253
+
254
+ if not limits:
255
+ return []
256
+
257
+ await models.concurrency_limits_v2.bulk_decrement_active_slots(
258
+ session=session,
259
+ concurrency_limit_ids=[limit.id for limit in limits if bool(limit.active)],
260
+ slots=slots,
261
+ occupancy_seconds=occupancy_seconds,
262
+ )
263
+
264
+ return [
265
+ MinimalConcurrencyLimitResponse(
266
+ id=limit.id, name=str(limit.name), limit=limit.limit
267
+ )
268
+ for limit in limits
269
+ ]
@@ -0,0 +1,38 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from fastapi import Depends, Query, status
4
+ from starlette.exceptions import HTTPException
5
+
6
+ from prefect.logging import get_logger
7
+ from prefect.server import models, schemas
8
+ from prefect.server.database import PrefectDBInterface, provide_database_interface
9
+ from prefect.server.utilities.server import PrefectRouter
10
+ from prefect.settings import PREFECT_SERVER_CSRF_PROTECTION_ENABLED
11
+
12
+ if TYPE_CHECKING:
13
+ import logging
14
+
15
+ logger: "logging.Logger" = get_logger("server.api")
16
+
17
+ router: PrefectRouter = PrefectRouter(prefix="/csrf-token")
18
+
19
+
20
+ @router.get("")
21
+ async def create_csrf_token(
22
+ db: PrefectDBInterface = Depends(provide_database_interface),
23
+ client: str = Query(..., description="The client to create a CSRF token for"),
24
+ ) -> schemas.core.CsrfToken:
25
+ """Create or update a CSRF token for a client"""
26
+ if PREFECT_SERVER_CSRF_PROTECTION_ENABLED.value() is False:
27
+ raise HTTPException(
28
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
29
+ detail="CSRF protection is disabled.",
30
+ )
31
+
32
+ async with db.session_context(begin_transaction=True) as session:
33
+ token = await models.csrf_token.create_or_update_csrf_token(
34
+ session=session, client=client
35
+ )
36
+ await models.csrf_token.delete_expired_tokens(session=session)
37
+
38
+ return token
@@ -0,0 +1,196 @@
1
+ """
2
+ Utilities for injecting FastAPI dependencies.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import re
9
+ from base64 import b64decode
10
+ from typing import Annotated, Any, Optional
11
+ from uuid import UUID
12
+
13
+ from fastapi import Body, Depends, Header, HTTPException, status
14
+ from packaging.version import Version
15
+ from starlette.requests import Request
16
+
17
+ from prefect.server import schemas
18
+ from prefect.settings import PREFECT_API_DEFAULT_LIMIT
19
+
20
+
21
+ def provide_request_api_version(
22
+ x_prefect_api_version: str = Header(None),
23
+ ) -> Version | None:
24
+ if not x_prefect_api_version:
25
+ return
26
+
27
+ # parse version
28
+ try:
29
+ _, _, _ = [int(v) for v in x_prefect_api_version.split(".")]
30
+ except ValueError:
31
+ raise HTTPException(
32
+ status_code=status.HTTP_400_BAD_REQUEST,
33
+ detail=(
34
+ "Invalid X-PREFECT-API-VERSION header format.Expected header in format"
35
+ f" 'x.y.z' but received {x_prefect_api_version}"
36
+ ),
37
+ )
38
+ return Version(x_prefect_api_version)
39
+
40
+
41
+ class EnforceMinimumAPIVersion:
42
+ """
43
+ FastAPI Dependency used to check compatibility between the version of the api
44
+ and a given request.
45
+
46
+ Looks for the header 'X-PREFECT-API-VERSION' in the request and compares it
47
+ to the api's version. Rejects requests that are lower than the minimum version.
48
+ """
49
+
50
+ def __init__(self, minimum_api_version: str, logger: logging.Logger):
51
+ self.minimum_api_version = minimum_api_version
52
+ versions = [int(v) for v in minimum_api_version.split(".")]
53
+ self.api_major: int = versions[0]
54
+ self.api_minor: int = versions[1]
55
+ self.api_patch: int = versions[2]
56
+ self.logger = logger
57
+
58
+ async def __call__(
59
+ self,
60
+ x_prefect_api_version: str = Header(None),
61
+ ) -> None:
62
+ request_version = x_prefect_api_version
63
+
64
+ # if no version header, assume latest and continue
65
+ if not request_version:
66
+ return
67
+
68
+ # parse version
69
+ try:
70
+ major, minor, patch = [int(v) for v in request_version.split(".")]
71
+ except ValueError:
72
+ await self._notify_of_invalid_value(request_version)
73
+ raise HTTPException(
74
+ status_code=status.HTTP_400_BAD_REQUEST,
75
+ detail=(
76
+ "Invalid X-PREFECT-API-VERSION header format."
77
+ f"Expected header in format 'x.y.z' but received {request_version}"
78
+ ),
79
+ )
80
+
81
+ if (major, minor, patch) < (self.api_major, self.api_minor, self.api_patch):
82
+ await self._notify_of_outdated_version(request_version)
83
+ raise HTTPException(
84
+ status_code=status.HTTP_400_BAD_REQUEST,
85
+ detail=(
86
+ f"The request specified API version {request_version} but this "
87
+ f"server requires version {self.minimum_api_version} or higher."
88
+ ),
89
+ )
90
+
91
+ async def _notify_of_invalid_value(self, request_version: str):
92
+ self.logger.error(
93
+ f"Invalid X-PREFECT-API-VERSION header format: '{request_version}'"
94
+ )
95
+
96
+ async def _notify_of_outdated_version(self, request_version: str):
97
+ self.logger.error(
98
+ f"X-PREFECT-API-VERSION header specifies version '{request_version}' "
99
+ f"but minimum allowed version is '{self.minimum_api_version}'"
100
+ )
101
+
102
+
103
+ def LimitBody() -> Any:
104
+ """
105
+ A `fastapi.Depends` factory for pulling a `limit: int` parameter from the
106
+ request body while determining the default from the current settings.
107
+ """
108
+
109
+ def get_limit(
110
+ limit: int = Body(
111
+ None,
112
+ description="Defaults to PREFECT_API_DEFAULT_LIMIT if not provided.",
113
+ ),
114
+ ):
115
+ default_limit = PREFECT_API_DEFAULT_LIMIT.value()
116
+ limit = limit if limit is not None else default_limit
117
+ if not limit >= 0:
118
+ raise HTTPException(
119
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
120
+ detail="Invalid limit: must be greater than or equal to 0.",
121
+ )
122
+ if limit > default_limit:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
125
+ detail=f"Invalid limit: must be less than or equal to {default_limit}.",
126
+ )
127
+ return limit
128
+
129
+ return Depends(get_limit)
130
+
131
+
132
+ def get_created_by(
133
+ prefect_automation_id: Optional[UUID] = Header(None, include_in_schema=False),
134
+ prefect_automation_name: Optional[str] = Header(None, include_in_schema=False),
135
+ ) -> Optional[schemas.core.CreatedBy]:
136
+ """A dependency that returns the provenance information to use when creating objects
137
+ during this API call."""
138
+ if prefect_automation_id and prefect_automation_name:
139
+ try:
140
+ display_value = b64decode(prefect_automation_name.encode()).decode()
141
+ except Exception:
142
+ display_value = None
143
+
144
+ if display_value:
145
+ return schemas.core.CreatedBy(
146
+ id=prefect_automation_id,
147
+ type="AUTOMATION",
148
+ display_value=display_value,
149
+ )
150
+
151
+ return None
152
+
153
+
154
+ def get_updated_by(
155
+ prefect_automation_id: Optional[UUID] = Header(None, include_in_schema=False),
156
+ prefect_automation_name: Optional[str] = Header(None, include_in_schema=False),
157
+ ) -> Optional[schemas.core.UpdatedBy]:
158
+ """A dependency that returns the provenance information to use when updating objects
159
+ during this API call."""
160
+ if prefect_automation_id and prefect_automation_name:
161
+ return schemas.core.UpdatedBy(
162
+ id=prefect_automation_id,
163
+ type="AUTOMATION",
164
+ display_value=prefect_automation_name,
165
+ )
166
+
167
+ return None
168
+
169
+
170
+ def is_ephemeral_request(request: Request) -> bool:
171
+ """
172
+ A dependency that returns whether the request is to an ephemeral server.
173
+ """
174
+ return "ephemeral-prefect" in str(request.base_url)
175
+
176
+
177
+ PREFECT_CLIENT_USER_AGENT_PATTERN = re.compile(
178
+ r"^prefect/(\d+\.\d+\.\d+(?:[a-z.+0-9]+)?) \(API \S+\)$"
179
+ )
180
+
181
+
182
+ def get_prefect_client_version(
183
+ user_agent: Annotated[Optional[str], Header(include_in_schema=False)] = None,
184
+ ) -> Optional[str]:
185
+ """
186
+ Attempts to parse out the Prefect client version from the User-Agent header.
187
+
188
+ The Prefect client sets the User-Agent header like so:
189
+ f"prefect/{prefect.__version__} (API {constants.SERVER_API_VERSION})"
190
+ """
191
+ if not user_agent:
192
+ return None
193
+
194
+ if client_version := PREFECT_CLIENT_USER_AGENT_PATTERN.match(user_agent):
195
+ return client_version.group(1)
196
+ return None