prefect-client 3.2.2__py3-none-any.whl → 3.2.4__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 (50) hide show
  1. prefect/__init__.py +15 -8
  2. prefect/_build_info.py +5 -0
  3. prefect/client/orchestration/__init__.py +16 -5
  4. prefect/main.py +0 -2
  5. prefect/server/api/__init__.py +34 -0
  6. prefect/server/api/admin.py +85 -0
  7. prefect/server/api/artifacts.py +224 -0
  8. prefect/server/api/automations.py +239 -0
  9. prefect/server/api/block_capabilities.py +25 -0
  10. prefect/server/api/block_documents.py +164 -0
  11. prefect/server/api/block_schemas.py +153 -0
  12. prefect/server/api/block_types.py +211 -0
  13. prefect/server/api/clients.py +246 -0
  14. prefect/server/api/collections.py +75 -0
  15. prefect/server/api/concurrency_limits.py +286 -0
  16. prefect/server/api/concurrency_limits_v2.py +269 -0
  17. prefect/server/api/csrf_token.py +38 -0
  18. prefect/server/api/dependencies.py +196 -0
  19. prefect/server/api/deployments.py +941 -0
  20. prefect/server/api/events.py +300 -0
  21. prefect/server/api/flow_run_notification_policies.py +120 -0
  22. prefect/server/api/flow_run_states.py +52 -0
  23. prefect/server/api/flow_runs.py +867 -0
  24. prefect/server/api/flows.py +210 -0
  25. prefect/server/api/logs.py +43 -0
  26. prefect/server/api/middleware.py +73 -0
  27. prefect/server/api/root.py +35 -0
  28. prefect/server/api/run_history.py +170 -0
  29. prefect/server/api/saved_searches.py +99 -0
  30. prefect/server/api/server.py +891 -0
  31. prefect/server/api/task_run_states.py +52 -0
  32. prefect/server/api/task_runs.py +342 -0
  33. prefect/server/api/task_workers.py +31 -0
  34. prefect/server/api/templates.py +35 -0
  35. prefect/server/api/ui/__init__.py +3 -0
  36. prefect/server/api/ui/flow_runs.py +128 -0
  37. prefect/server/api/ui/flows.py +173 -0
  38. prefect/server/api/ui/schemas.py +63 -0
  39. prefect/server/api/ui/task_runs.py +175 -0
  40. prefect/server/api/validation.py +382 -0
  41. prefect/server/api/variables.py +181 -0
  42. prefect/server/api/work_queues.py +230 -0
  43. prefect/server/api/workers.py +656 -0
  44. prefect/settings/sources.py +18 -5
  45. {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/METADATA +10 -15
  46. {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/RECORD +48 -10
  47. {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/WHEEL +1 -2
  48. prefect/_version.py +0 -21
  49. prefect_client-3.2.2.dist-info/top_level.txt +0 -1
  50. {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,891 @@
1
+ """
2
+ Defines the Prefect REST API FastAPI app.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import atexit
9
+ import base64
10
+ import contextlib
11
+ import gc
12
+ import mimetypes
13
+ import os
14
+ import random
15
+ import shutil
16
+ import socket
17
+ import sqlite3
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from contextlib import asynccontextmanager
22
+ from functools import wraps
23
+ from hashlib import sha256
24
+ from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Optional
25
+
26
+ import anyio
27
+ import asyncpg
28
+ import httpx
29
+ import sqlalchemy as sa
30
+ import sqlalchemy.exc
31
+ import sqlalchemy.orm.exc
32
+ from fastapi import Depends, FastAPI, Request, Response, status
33
+ from fastapi.encoders import jsonable_encoder
34
+ from fastapi.exceptions import RequestValidationError
35
+ from fastapi.middleware.cors import CORSMiddleware
36
+ from fastapi.middleware.gzip import GZipMiddleware
37
+ from fastapi.openapi.utils import get_openapi
38
+ from fastapi.responses import JSONResponse
39
+ from fastapi.staticfiles import StaticFiles
40
+ from starlette.exceptions import HTTPException
41
+ from typing_extensions import Self
42
+
43
+ import prefect
44
+ import prefect.server.api as api
45
+ import prefect.settings
46
+ from prefect.client.constants import SERVER_API_VERSION
47
+ from prefect.logging import get_logger
48
+ from prefect.server.api.dependencies import EnforceMinimumAPIVersion
49
+ from prefect.server.exceptions import ObjectNotFoundError
50
+ from prefect.server.services.base import RunInAllServers, Service
51
+ from prefect.server.utilities.database import get_dialect
52
+ from prefect.settings import (
53
+ PREFECT_API_DATABASE_CONNECTION_URL,
54
+ PREFECT_API_LOG_RETRYABLE_ERRORS,
55
+ PREFECT_DEBUG_MODE,
56
+ PREFECT_MEMO_STORE_PATH,
57
+ PREFECT_MEMOIZE_BLOCK_AUTO_REGISTRATION,
58
+ PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS,
59
+ PREFECT_UI_SERVE_BASE,
60
+ get_current_settings,
61
+ )
62
+ from prefect.utilities.hashing import hash_objects
63
+
64
+ if TYPE_CHECKING:
65
+ import logging
66
+
67
+ TITLE = "Prefect Server"
68
+ API_TITLE = "Prefect Prefect REST API"
69
+ UI_TITLE = "Prefect Prefect REST API UI"
70
+ API_VERSION: str = prefect.__version__
71
+ # migrations should run only once per app start; the ephemeral API can potentially
72
+ # create multiple apps in a single process
73
+ LIFESPAN_RAN_FOR_APP: set[Any] = set()
74
+
75
+ logger: "logging.Logger" = get_logger("server")
76
+
77
+ enforce_minimum_version: EnforceMinimumAPIVersion = EnforceMinimumAPIVersion(
78
+ # this should be <= SERVER_API_VERSION; clients that send
79
+ # a version header under this value will be rejected
80
+ minimum_api_version="0.8.0",
81
+ logger=logger,
82
+ )
83
+
84
+
85
+ API_ROUTERS = (
86
+ api.flows.router,
87
+ api.flow_runs.router,
88
+ api.task_runs.router,
89
+ api.flow_run_states.router,
90
+ api.task_run_states.router,
91
+ api.flow_run_notification_policies.router,
92
+ api.deployments.router,
93
+ api.saved_searches.router,
94
+ api.logs.router,
95
+ api.concurrency_limits.router,
96
+ api.concurrency_limits_v2.router,
97
+ api.block_types.router,
98
+ api.block_documents.router,
99
+ api.workers.router,
100
+ api.task_workers.router,
101
+ api.work_queues.router,
102
+ api.artifacts.router,
103
+ api.block_schemas.router,
104
+ api.block_capabilities.router,
105
+ api.collections.router,
106
+ api.variables.router,
107
+ api.csrf_token.router,
108
+ api.events.router,
109
+ api.automations.router,
110
+ api.templates.router,
111
+ api.ui.flows.router,
112
+ api.ui.flow_runs.router,
113
+ api.ui.schemas.router,
114
+ api.ui.task_runs.router,
115
+ api.admin.router,
116
+ api.root.router,
117
+ )
118
+
119
+ SQLITE_LOCKED_MSG = "database is locked"
120
+
121
+
122
+ class SPAStaticFiles(StaticFiles):
123
+ """
124
+ Implementation of `StaticFiles` for serving single page applications.
125
+
126
+ Adds `get_response` handling to ensure that when a resource isn't found the
127
+ application still returns the index.
128
+ """
129
+
130
+ async def get_response(self, path: str, scope: Any) -> Response:
131
+ try:
132
+ return await super().get_response(path, scope)
133
+ except HTTPException:
134
+ return await super().get_response("./index.html", scope)
135
+
136
+
137
+ class RequestLimitMiddleware:
138
+ """
139
+ A middleware that limits the number of concurrent requests handled by the API.
140
+
141
+ This is a blunt tool for limiting SQLite concurrent writes which will cause failures
142
+ at high volume. Ideally, we would only apply the limit to routes that perform
143
+ writes.
144
+ """
145
+
146
+ def __init__(self, app: Any, limit: float):
147
+ self.app = app
148
+ self._limiter = anyio.CapacityLimiter(limit)
149
+
150
+ async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
151
+ async with self._limiter:
152
+ await self.app(scope, receive, send)
153
+
154
+
155
+ async def validation_exception_handler(
156
+ request: Request, exc: RequestValidationError
157
+ ) -> JSONResponse:
158
+ """Provide a detailed message for request validation errors."""
159
+ return JSONResponse(
160
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
161
+ content=jsonable_encoder(
162
+ {
163
+ "exception_message": "Invalid request received.",
164
+ "exception_detail": exc.errors(),
165
+ "request_body": exc.body,
166
+ }
167
+ ),
168
+ )
169
+
170
+
171
+ async def integrity_exception_handler(request: Request, exc: Exception) -> JSONResponse:
172
+ """Capture database integrity errors."""
173
+ logger.error("Encountered exception in request:", exc_info=True)
174
+ return JSONResponse(
175
+ content={
176
+ "detail": (
177
+ "Data integrity conflict. This usually means a "
178
+ "unique or foreign key constraint was violated. "
179
+ "See server logs for details."
180
+ )
181
+ },
182
+ status_code=status.HTTP_409_CONFLICT,
183
+ )
184
+
185
+
186
+ def is_client_retryable_exception(exc: Exception) -> bool:
187
+ if isinstance(exc, sqlalchemy.exc.OperationalError) and isinstance(
188
+ exc.orig, sqlite3.OperationalError
189
+ ):
190
+ if getattr(exc.orig, "sqlite_errorname", None) in {
191
+ "SQLITE_BUSY",
192
+ "SQLITE_BUSY_SNAPSHOT",
193
+ } or SQLITE_LOCKED_MSG in getattr(exc.orig, "args", []):
194
+ return True
195
+ else:
196
+ # Avoid falling through to the generic `DBAPIError` case below
197
+ return False
198
+
199
+ if isinstance(
200
+ exc,
201
+ (
202
+ sqlalchemy.exc.DBAPIError,
203
+ asyncpg.exceptions.QueryCanceledError,
204
+ asyncpg.exceptions.ConnectionDoesNotExistError,
205
+ asyncpg.exceptions.CannotConnectNowError,
206
+ sqlalchemy.exc.InvalidRequestError,
207
+ sqlalchemy.orm.exc.DetachedInstanceError,
208
+ ),
209
+ ):
210
+ return True
211
+
212
+ return False
213
+
214
+
215
+ def replace_placeholder_string_in_files(
216
+ directory: str,
217
+ placeholder: str,
218
+ replacement: str,
219
+ allowed_extensions: list[str] | None = None,
220
+ ) -> None:
221
+ """
222
+ Recursively loops through all files in the given directory and replaces
223
+ a placeholder string.
224
+ """
225
+ if allowed_extensions is None:
226
+ allowed_extensions = [".txt", ".html", ".css", ".js", ".json", ".txt"]
227
+
228
+ for root, _, files in os.walk(directory):
229
+ for file in files:
230
+ if any(file.endswith(ext) for ext in allowed_extensions):
231
+ file_path = os.path.join(root, file)
232
+
233
+ with open(file_path, "r", encoding="utf-8") as file:
234
+ file_data = file.read()
235
+
236
+ file_data = file_data.replace(placeholder, replacement)
237
+
238
+ with open(file_path, "w", encoding="utf-8") as file:
239
+ file.write(file_data)
240
+
241
+
242
+ def copy_directory(directory: str, path: str) -> None:
243
+ os.makedirs(path, exist_ok=True)
244
+ for item in os.listdir(directory):
245
+ source = os.path.join(directory, item)
246
+ destination = os.path.join(path, item)
247
+
248
+ if os.path.isdir(source):
249
+ if os.path.exists(destination):
250
+ shutil.rmtree(destination)
251
+ shutil.copytree(source, destination, symlinks=True)
252
+ else:
253
+ shutil.copy2(source, destination)
254
+
255
+
256
+ async def custom_internal_exception_handler(
257
+ request: Request, exc: Exception
258
+ ) -> JSONResponse:
259
+ """
260
+ Log a detailed exception for internal server errors before returning.
261
+
262
+ Send 503 for errors clients can retry on.
263
+ """
264
+ if is_client_retryable_exception(exc):
265
+ if PREFECT_API_LOG_RETRYABLE_ERRORS.value():
266
+ logger.error("Encountered retryable exception in request:", exc_info=True)
267
+
268
+ return JSONResponse(
269
+ content={"exception_message": "Service Unavailable"},
270
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
271
+ )
272
+
273
+ logger.error("Encountered exception in request:", exc_info=True)
274
+
275
+ return JSONResponse(
276
+ content={"exception_message": "Internal Server Error"},
277
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
278
+ )
279
+
280
+
281
+ async def prefect_object_not_found_exception_handler(
282
+ request: Request, exc: ObjectNotFoundError
283
+ ) -> JSONResponse:
284
+ """Return 404 status code on object not found exceptions."""
285
+ return JSONResponse(
286
+ content={"exception_message": str(exc)}, status_code=status.HTTP_404_NOT_FOUND
287
+ )
288
+
289
+
290
+ def create_api_app(
291
+ dependencies: list[Any] | None = None,
292
+ health_check_path: str = "/health",
293
+ version_check_path: str = "/version",
294
+ fast_api_app_kwargs: dict[str, Any] | None = None,
295
+ final: bool = False,
296
+ ) -> FastAPI:
297
+ """
298
+ Create a FastAPI app that includes the Prefect REST API
299
+
300
+ Args:
301
+ dependencies: a list of global dependencies to add to each Prefect REST API router
302
+ health_check_path: the health check route path
303
+ fast_api_app_kwargs: kwargs to pass to the FastAPI constructor
304
+ final: whether this will be the last instance of the Prefect server to be
305
+ created in this process, so that additional optimizations may be applied
306
+
307
+ Returns:
308
+ a FastAPI app that serves the Prefect REST API
309
+ """
310
+ fast_api_app_kwargs = fast_api_app_kwargs or {}
311
+ api_app = FastAPI(title=API_TITLE, **fast_api_app_kwargs)
312
+ api_app.add_middleware(GZipMiddleware)
313
+
314
+ @api_app.get(health_check_path, tags=["Root"])
315
+ async def health_check() -> bool: # type: ignore[reportUnusedFunction]
316
+ return True
317
+
318
+ @api_app.get(version_check_path, tags=["Root"])
319
+ async def server_version() -> str: # type: ignore[reportUnusedFunction]
320
+ return SERVER_API_VERSION
321
+
322
+ # always include version checking
323
+ if dependencies is None:
324
+ dependencies = [Depends(enforce_minimum_version)]
325
+ else:
326
+ dependencies.append(Depends(enforce_minimum_version))
327
+
328
+ for router in API_ROUTERS:
329
+ api_app.include_router(router, dependencies=dependencies)
330
+ if final:
331
+ # Important note about how FastAPI works:
332
+ #
333
+ # When including a router, FastAPI copies the routes and builds entirely new
334
+ # Pydantic models to represent the request bodies of the routes in the
335
+ # router. This is because the dependencies may change if the same router is
336
+ # included multiple times, but it also means that we are holding onto an
337
+ # entire set of Pydantic models on the original routers for the duration of
338
+ # the server process that will never be used.
339
+ #
340
+ # Because Prefect does not reuse routers, we are free to clean up the routes
341
+ # because we know they won't be used again. Thus, if we have the hint that
342
+ # this is the final instance we will create in this process, we can clean up
343
+ # the routes on the original source routers to conserve memory (~50-55MB as
344
+ # of introducing this change).
345
+ del router.routes
346
+
347
+ if final:
348
+ gc.collect()
349
+
350
+ auth_string = prefect.settings.PREFECT_SERVER_API_AUTH_STRING.value()
351
+
352
+ if auth_string is not None:
353
+
354
+ @api_app.middleware("http")
355
+ async def token_validation(request: Request, call_next: Any): # type: ignore[reportUnusedFunction]
356
+ header_token = request.headers.get("Authorization")
357
+
358
+ # used for probes in k8s and such
359
+ if request.url.path in ["/api/health", "/api/ready"]:
360
+ return await call_next(request)
361
+ try:
362
+ if header_token is None:
363
+ return JSONResponse(
364
+ status_code=status.HTTP_401_UNAUTHORIZED,
365
+ content={"exception_message": "Unauthorized"},
366
+ )
367
+ scheme, creds = header_token.split()
368
+ assert scheme == "Basic"
369
+ decoded = base64.b64decode(creds).decode("utf-8")
370
+ except Exception:
371
+ return JSONResponse(
372
+ status_code=status.HTTP_401_UNAUTHORIZED,
373
+ content={"exception_message": "Unauthorized"},
374
+ )
375
+ if decoded != auth_string:
376
+ return JSONResponse(
377
+ status_code=status.HTTP_401_UNAUTHORIZED,
378
+ content={"exception_message": "Unauthorized"},
379
+ )
380
+ return await call_next(request)
381
+
382
+ return api_app
383
+
384
+
385
+ def create_ui_app(ephemeral: bool) -> FastAPI:
386
+ ui_app = FastAPI(title=UI_TITLE)
387
+ base_url = prefect.settings.PREFECT_UI_SERVE_BASE.value()
388
+ cache_key = f"{prefect.__version__}:{base_url}"
389
+ stripped_base_url = base_url.rstrip("/")
390
+ static_dir = (
391
+ prefect.settings.PREFECT_UI_STATIC_DIRECTORY.value()
392
+ or prefect.__ui_static_subpath__
393
+ )
394
+ reference_file_name = "UI_SERVE_BASE"
395
+
396
+ if os.name == "nt":
397
+ # Windows defaults to text/plain for .js files
398
+ mimetypes.init()
399
+ mimetypes.add_type("application/javascript", ".js")
400
+
401
+ @ui_app.get(f"{stripped_base_url}/ui-settings")
402
+ def ui_settings() -> dict[str, Any]: # type: ignore[reportUnusedFunction]
403
+ return {
404
+ "api_url": prefect.settings.PREFECT_UI_API_URL.value(),
405
+ "csrf_enabled": prefect.settings.PREFECT_SERVER_CSRF_PROTECTION_ENABLED.value(),
406
+ "auth": "BASIC"
407
+ if prefect.settings.PREFECT_SERVER_API_AUTH_STRING.value()
408
+ else None,
409
+ "flags": [],
410
+ }
411
+
412
+ def reference_file_matches_base_url() -> bool:
413
+ reference_file_path = os.path.join(static_dir, reference_file_name)
414
+
415
+ if os.path.exists(static_dir):
416
+ try:
417
+ with open(reference_file_path, "r") as f:
418
+ return f.read() == cache_key
419
+ except FileNotFoundError:
420
+ return False
421
+ else:
422
+ return False
423
+
424
+ def create_ui_static_subpath() -> None:
425
+ if not os.path.exists(static_dir):
426
+ os.makedirs(static_dir)
427
+
428
+ copy_directory(str(prefect.__ui_static_path__), str(static_dir))
429
+ replace_placeholder_string_in_files(
430
+ str(static_dir),
431
+ "/PREFECT_UI_SERVE_BASE_REPLACE_PLACEHOLDER",
432
+ stripped_base_url,
433
+ )
434
+
435
+ # Create a file to indicate that the static files have been copied
436
+ # This is used to determine if the static files need to be copied again
437
+ # when the server is restarted
438
+ with open(os.path.join(static_dir, reference_file_name), "w") as f:
439
+ f.write(cache_key)
440
+
441
+ ui_app.add_middleware(GZipMiddleware)
442
+
443
+ if (
444
+ os.path.exists(prefect.__ui_static_path__)
445
+ and prefect.settings.PREFECT_UI_ENABLED.value()
446
+ and not ephemeral
447
+ ):
448
+ # If the static files have already been copied, check if the base_url has changed
449
+ # If it has, we delete the subpath directory and copy the files again
450
+ if not reference_file_matches_base_url():
451
+ create_ui_static_subpath()
452
+
453
+ ui_app.mount(
454
+ PREFECT_UI_SERVE_BASE.value(),
455
+ SPAStaticFiles(directory=static_dir),
456
+ name="ui_root",
457
+ )
458
+
459
+ return ui_app
460
+
461
+
462
+ APP_CACHE: dict[tuple[prefect.settings.Settings, bool], FastAPI] = {}
463
+
464
+
465
+ def _memoize_block_auto_registration(
466
+ fn: Callable[[], Awaitable[None]],
467
+ ) -> Callable[[], Awaitable[None]]:
468
+ """
469
+ Decorator to handle skipping the wrapped function if the block registry has
470
+ not changed since the last invocation
471
+ """
472
+ import toml
473
+
474
+ import prefect.plugins
475
+ from prefect.blocks.core import Block
476
+ from prefect.server.models.block_registration import _load_collection_blocks_data
477
+ from prefect.utilities.dispatch import get_registry_for_type
478
+
479
+ @wraps(fn)
480
+ async def wrapper(*args: Any, **kwargs: Any) -> None:
481
+ if not PREFECT_MEMOIZE_BLOCK_AUTO_REGISTRATION.value():
482
+ await fn(*args, **kwargs)
483
+ return
484
+
485
+ # Ensure collections are imported and have the opportunity to register types
486
+ # before loading the registry
487
+ prefect.plugins.load_prefect_collections()
488
+
489
+ blocks_registry = get_registry_for_type(Block)
490
+ collection_blocks_data = await _load_collection_blocks_data()
491
+ current_blocks_loading_hash = hash_objects(
492
+ blocks_registry,
493
+ collection_blocks_data,
494
+ PREFECT_API_DATABASE_CONNECTION_URL.value(),
495
+ hash_algo=sha256,
496
+ )
497
+
498
+ memo_store_path = PREFECT_MEMO_STORE_PATH.value()
499
+ try:
500
+ if memo_store_path.exists():
501
+ saved_blocks_loading_hash = toml.load(memo_store_path).get(
502
+ "block_auto_registration"
503
+ )
504
+ if (
505
+ saved_blocks_loading_hash is not None
506
+ and current_blocks_loading_hash == saved_blocks_loading_hash
507
+ ):
508
+ if PREFECT_DEBUG_MODE.value():
509
+ logger.debug(
510
+ "Skipping block loading due to matching hash for block "
511
+ "auto-registration found in memo store."
512
+ )
513
+ return
514
+ except Exception as exc:
515
+ logger.warning(
516
+ ""
517
+ f"Unable to read memo_store.toml from {PREFECT_MEMO_STORE_PATH} during "
518
+ f"block auto-registration: {exc!r}.\n"
519
+ "All blocks will be registered."
520
+ )
521
+
522
+ await fn(*args, **kwargs)
523
+
524
+ if current_blocks_loading_hash is not None:
525
+ try:
526
+ if not memo_store_path.exists():
527
+ memo_store_path.touch(mode=0o0600)
528
+
529
+ memo_store_path.write_text(
530
+ toml.dumps({"block_auto_registration": current_blocks_loading_hash})
531
+ )
532
+ except Exception as exc:
533
+ logger.warning(
534
+ "Unable to write to memo_store.toml at"
535
+ f" {PREFECT_MEMO_STORE_PATH} after block auto-registration:"
536
+ f" {exc!r}.\n Subsequent server start ups will perform block"
537
+ " auto-registration, which may result in slower server startup."
538
+ )
539
+
540
+ return wrapper
541
+
542
+
543
+ def create_app(
544
+ settings: Optional[prefect.settings.Settings] = None,
545
+ ephemeral: bool = False,
546
+ webserver_only: bool = False,
547
+ final: bool = False,
548
+ ignore_cache: bool = False,
549
+ ) -> FastAPI:
550
+ """
551
+ Create a FastAPI app that includes the Prefect REST API and UI
552
+
553
+ Args:
554
+ settings: The settings to use to create the app. If not set, settings are pulled
555
+ from the context.
556
+ ephemeral: If set, the application will be treated as ephemeral. The UI
557
+ and services will be disabled.
558
+ webserver_only: If set, the webserver and UI will be available but all background
559
+ services will be disabled.
560
+ final: whether this will be the last instance of the Prefect server to be
561
+ created in this process, so that additional optimizations may be applied
562
+ ignore_cache: If set, a new application will be created even if the settings
563
+ match. Otherwise, an application is returned from the cache.
564
+ """
565
+ settings = settings or prefect.settings.get_current_settings()
566
+ cache_key = (settings.hash_key(), ephemeral, webserver_only)
567
+ ephemeral = ephemeral or bool(os.getenv("PREFECT__SERVER_EPHEMERAL"))
568
+ webserver_only = webserver_only or bool(os.getenv("PREFECT__SERVER_WEBSERVER_ONLY"))
569
+ final = final or bool(os.getenv("PREFECT__SERVER_FINAL"))
570
+
571
+ from prefect.logging.configuration import setup_logging
572
+
573
+ setup_logging()
574
+
575
+ if cache_key in APP_CACHE and not ignore_cache:
576
+ return APP_CACHE[cache_key]
577
+
578
+ # TODO: Move these startup functions out of this closure into the top-level or
579
+ # another dedicated location
580
+ async def run_migrations():
581
+ """Ensure the database is created and up to date with the current migrations"""
582
+ if prefect.settings.PREFECT_API_DATABASE_MIGRATE_ON_START:
583
+ from prefect.server.database import provide_database_interface
584
+
585
+ db = provide_database_interface()
586
+ await db.create_db()
587
+
588
+ @_memoize_block_auto_registration
589
+ async def add_block_types():
590
+ """Add all registered blocks to the database"""
591
+ if not prefect.settings.PREFECT_API_BLOCKS_REGISTER_ON_START:
592
+ return
593
+
594
+ from prefect.server.database import provide_database_interface
595
+ from prefect.server.models.block_registration import run_block_auto_registration
596
+
597
+ db = provide_database_interface()
598
+ session = await db.session()
599
+
600
+ async with session:
601
+ await run_block_auto_registration(session=session)
602
+
603
+ @asynccontextmanager
604
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
605
+ if app not in LIFESPAN_RAN_FOR_APP:
606
+ await run_migrations()
607
+ await add_block_types()
608
+
609
+ Services: type[Service] = Service
610
+ if ephemeral or webserver_only:
611
+ Services = RunInAllServers
612
+
613
+ async with Services.running():
614
+ LIFESPAN_RAN_FOR_APP.add(app)
615
+ yield
616
+ else:
617
+ yield
618
+
619
+ def on_service_exit(service: Service, task: asyncio.Task[None]) -> None:
620
+ """
621
+ Added as a callback for completion of services to log exit
622
+ """
623
+ try:
624
+ # Retrieving the result will raise the exception
625
+ task.result()
626
+ except Exception:
627
+ logger.error(f"{service.name} service failed!", exc_info=True)
628
+ else:
629
+ logger.info(f"{service.name} service stopped!")
630
+
631
+ app = FastAPI(
632
+ title=TITLE,
633
+ version=API_VERSION,
634
+ lifespan=lifespan,
635
+ )
636
+ api_app = create_api_app(
637
+ fast_api_app_kwargs={
638
+ "exception_handlers": {
639
+ # NOTE: FastAPI special cases the generic `Exception` handler and
640
+ # registers it as a separate middleware from the others
641
+ Exception: custom_internal_exception_handler,
642
+ RequestValidationError: validation_exception_handler,
643
+ sa.exc.IntegrityError: integrity_exception_handler,
644
+ ObjectNotFoundError: prefect_object_not_found_exception_handler,
645
+ }
646
+ },
647
+ final=final,
648
+ )
649
+ ui_app = create_ui_app(ephemeral)
650
+
651
+ # middleware
652
+ app.add_middleware(
653
+ CORSMiddleware,
654
+ allow_origins=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_ORIGINS.value().split(
655
+ ","
656
+ ),
657
+ allow_methods=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_METHODS.value().split(
658
+ ","
659
+ ),
660
+ allow_headers=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_HEADERS.value().split(
661
+ ","
662
+ ),
663
+ )
664
+
665
+ # Limit the number of concurrent requests when using a SQLite database to reduce
666
+ # chance of errors where the database cannot be opened due to a high number of
667
+ # concurrent writes
668
+ if (
669
+ get_dialect(prefect.settings.PREFECT_API_DATABASE_CONNECTION_URL.value()).name
670
+ == "sqlite"
671
+ ):
672
+ app.add_middleware(RequestLimitMiddleware, limit=100)
673
+
674
+ if prefect.settings.PREFECT_SERVER_CSRF_PROTECTION_ENABLED.value():
675
+ app.add_middleware(api.middleware.CsrfMiddleware)
676
+
677
+ if prefect.settings.PREFECT_API_ENABLE_METRICS:
678
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
679
+
680
+ @api_app.get("/metrics")
681
+ async def metrics() -> Response: # type: ignore[reportUnusedFunction]
682
+ return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
683
+
684
+ api_app.mount(
685
+ "/static",
686
+ StaticFiles(
687
+ directory=os.path.join(
688
+ os.path.dirname(os.path.realpath(__file__)), "static"
689
+ )
690
+ ),
691
+ name="static",
692
+ )
693
+ app.api_app = api_app
694
+ app.mount("/api", app=api_app, name="api")
695
+ app.mount("/", app=ui_app, name="ui")
696
+
697
+ def openapi():
698
+ """
699
+ Convenience method for extracting the user facing OpenAPI schema from the API app.
700
+
701
+ This method is attached to the global public app for easy access.
702
+ """
703
+ partial_schema = get_openapi(
704
+ title=API_TITLE,
705
+ version=API_VERSION,
706
+ routes=api_app.routes,
707
+ )
708
+ new_schema = partial_schema.copy()
709
+ new_schema["paths"] = {}
710
+ for path, value in partial_schema["paths"].items():
711
+ new_schema["paths"][f"/api{path}"] = value
712
+
713
+ new_schema["info"]["x-logo"] = {"url": "static/prefect-logo-mark-gradient.png"}
714
+ return new_schema
715
+
716
+ app.openapi = openapi
717
+
718
+ APP_CACHE[cache_key] = app
719
+ return app
720
+
721
+
722
+ subprocess_server_logger: "logging.Logger" = get_logger()
723
+
724
+
725
+ class SubprocessASGIServer:
726
+ _instances: dict[int | None, "SubprocessASGIServer"] = {}
727
+ _port_range: range = range(8000, 9000)
728
+
729
+ def __new__(cls, port: int | None = None, *args: Any, **kwargs: Any) -> Self:
730
+ """
731
+ Return an instance of the server associated with the provided port.
732
+ Prevents multiple instances from being created for the same port.
733
+ """
734
+ if port not in cls._instances:
735
+ instance = super().__new__(cls)
736
+ cls._instances[port] = instance
737
+ return cls._instances[port]
738
+
739
+ def __init__(self, port: Optional[int] = None):
740
+ # This ensures initialization happens only once
741
+ if not hasattr(self, "_initialized"):
742
+ self.port: Optional[int] = port
743
+ self.server_process: subprocess.Popen[Any] | None = None
744
+ self.running: bool = False
745
+ self._initialized = True
746
+
747
+ def find_available_port(self) -> int:
748
+ max_attempts = 10
749
+ for _ in range(max_attempts):
750
+ port = random.choice(self._port_range)
751
+ if self.is_port_available(port):
752
+ return port
753
+ time.sleep(random.uniform(0.1, 0.5)) # Random backoff
754
+ raise RuntimeError("Unable to find an available port after multiple attempts")
755
+
756
+ @staticmethod
757
+ def is_port_available(port: int) -> bool:
758
+ with contextlib.closing(
759
+ socket.socket(socket.AF_INET, socket.SOCK_STREAM)
760
+ ) as sock:
761
+ try:
762
+ sock.bind(("127.0.0.1", port))
763
+ return True
764
+ except socket.error:
765
+ return False
766
+
767
+ @property
768
+ def address(self) -> str:
769
+ return f"http://127.0.0.1:{self.port}"
770
+
771
+ @property
772
+ def api_url(self) -> str:
773
+ return f"{self.address}/api"
774
+
775
+ def start(self, timeout: Optional[int] = None) -> None:
776
+ """
777
+ Start the server in a separate process. Safe to call multiple times; only starts
778
+ the server once.
779
+
780
+ Args:
781
+ timeout: The maximum time to wait for the server to start
782
+ """
783
+ if not self.running:
784
+ if self.port is None:
785
+ self.port = self.find_available_port()
786
+ assert self.port is not None, "Port must be provided or available"
787
+ help_message = (
788
+ f"Starting temporary server on {self.address}\nSee "
789
+ "https://docs.prefect.io/3.0/manage/self-host#self-host-a-prefect-server "
790
+ "for more information on running a dedicated Prefect server."
791
+ )
792
+ subprocess_server_logger.info(help_message)
793
+ try:
794
+ self.running = True
795
+ self.server_process = self._run_uvicorn_command()
796
+ atexit.register(self.stop)
797
+ with httpx.Client() as client:
798
+ response = None
799
+ elapsed_time = 0
800
+ max_wait_time = (
801
+ timeout
802
+ or PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS.value()
803
+ )
804
+ while elapsed_time < max_wait_time:
805
+ if self.server_process.poll() == 3:
806
+ self.port = self.find_available_port()
807
+ self.server_process = self._run_uvicorn_command()
808
+ continue
809
+ try:
810
+ response = client.get(f"{self.api_url}/health")
811
+ except httpx.ConnectError:
812
+ pass
813
+ else:
814
+ if response.status_code == 200:
815
+ break
816
+ time.sleep(0.1)
817
+ elapsed_time += 0.1
818
+ if response:
819
+ response.raise_for_status()
820
+ if not response:
821
+ error_message = "Timed out while attempting to connect to ephemeral Prefect API server."
822
+ if self.server_process.poll() is not None:
823
+ error_message += f" Ephemeral server process exited with code {self.server_process.returncode}."
824
+ if self.server_process.stdout:
825
+ error_message += (
826
+ f" stdout: {self.server_process.stdout.read()}"
827
+ )
828
+ if self.server_process.stderr:
829
+ error_message += (
830
+ f" stderr: {self.server_process.stderr.read()}"
831
+ )
832
+ raise RuntimeError(error_message)
833
+ except Exception:
834
+ self.running = False
835
+ raise
836
+
837
+ def _run_uvicorn_command(self) -> subprocess.Popen[Any]:
838
+ # used to turn off serving the UI
839
+ server_env = {
840
+ "PREFECT_UI_ENABLED": "0",
841
+ "PREFECT__SERVER_EPHEMERAL": "1",
842
+ "PREFECT__SERVER_FINAL": "1",
843
+ }
844
+ return subprocess.Popen(
845
+ args=[
846
+ sys.executable,
847
+ "-m",
848
+ "uvicorn",
849
+ "--app-dir",
850
+ str(prefect.__module_path__.parent),
851
+ "--factory",
852
+ "prefect.server.api.server:create_app",
853
+ "--host",
854
+ "127.0.0.1",
855
+ "--port",
856
+ str(self.port),
857
+ "--log-level",
858
+ "error",
859
+ "--lifespan",
860
+ "on",
861
+ ],
862
+ env={
863
+ **os.environ,
864
+ **server_env,
865
+ **get_current_settings().to_environment_variables(exclude_unset=True),
866
+ },
867
+ )
868
+
869
+ def stop(self) -> None:
870
+ if self.server_process:
871
+ subprocess_server_logger.info(
872
+ f"Stopping temporary server on {self.address}"
873
+ )
874
+ self.server_process.terminate()
875
+ try:
876
+ self.server_process.wait(timeout=5)
877
+ except subprocess.TimeoutExpired:
878
+ self.server_process.kill()
879
+ finally:
880
+ self.server_process = None
881
+ if self.port in self._instances:
882
+ del self._instances[self.port]
883
+ if self.running:
884
+ self.running = False
885
+
886
+ def __enter__(self) -> Self:
887
+ self.start()
888
+ return self
889
+
890
+ def __exit__(self, *args: Any) -> None:
891
+ self.stop()