zenml-nightly 0.83.0.dev20250619__py3-none-any.whl → 0.83.0.dev20250622__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 (47) hide show
  1. zenml/VERSION +1 -1
  2. zenml/__init__.py +12 -2
  3. zenml/analytics/context.py +4 -2
  4. zenml/config/server_config.py +6 -1
  5. zenml/constants.py +3 -0
  6. zenml/entrypoints/step_entrypoint_configuration.py +14 -0
  7. zenml/models/__init__.py +15 -0
  8. zenml/models/v2/core/api_transaction.py +193 -0
  9. zenml/models/v2/core/pipeline_build.py +4 -0
  10. zenml/models/v2/core/pipeline_deployment.py +8 -1
  11. zenml/models/v2/core/pipeline_run.py +7 -0
  12. zenml/models/v2/core/step_run.py +6 -0
  13. zenml/orchestrators/input_utils.py +34 -11
  14. zenml/utils/json_utils.py +1 -1
  15. zenml/zen_server/auth.py +53 -31
  16. zenml/zen_server/cloud_utils.py +19 -7
  17. zenml/zen_server/middleware.py +424 -0
  18. zenml/zen_server/rbac/endpoint_utils.py +5 -2
  19. zenml/zen_server/rbac/utils.py +12 -7
  20. zenml/zen_server/request_management.py +556 -0
  21. zenml/zen_server/routers/auth_endpoints.py +1 -0
  22. zenml/zen_server/routers/model_versions_endpoints.py +3 -3
  23. zenml/zen_server/routers/models_endpoints.py +3 -3
  24. zenml/zen_server/routers/pipeline_builds_endpoints.py +2 -2
  25. zenml/zen_server/routers/pipeline_deployments_endpoints.py +9 -4
  26. zenml/zen_server/routers/pipelines_endpoints.py +4 -4
  27. zenml/zen_server/routers/run_templates_endpoints.py +3 -3
  28. zenml/zen_server/routers/runs_endpoints.py +4 -4
  29. zenml/zen_server/routers/service_connectors_endpoints.py +6 -6
  30. zenml/zen_server/routers/steps_endpoints.py +3 -3
  31. zenml/zen_server/utils.py +230 -63
  32. zenml/zen_server/zen_server_api.py +34 -399
  33. zenml/zen_stores/migrations/versions/3d7e39f3ac92_split_up_step_configurations.py +138 -0
  34. zenml/zen_stores/migrations/versions/857843db1bcf_add_api_transaction_table.py +69 -0
  35. zenml/zen_stores/rest_zen_store.py +52 -42
  36. zenml/zen_stores/schemas/__init__.py +4 -0
  37. zenml/zen_stores/schemas/api_transaction_schemas.py +141 -0
  38. zenml/zen_stores/schemas/pipeline_deployment_schemas.py +88 -27
  39. zenml/zen_stores/schemas/pipeline_run_schemas.py +28 -11
  40. zenml/zen_stores/schemas/step_run_schemas.py +4 -4
  41. zenml/zen_stores/sql_zen_store.py +277 -42
  42. zenml/zen_stores/zen_store_interface.py +7 -1
  43. {zenml_nightly-0.83.0.dev20250619.dist-info → zenml_nightly-0.83.0.dev20250622.dist-info}/METADATA +1 -1
  44. {zenml_nightly-0.83.0.dev20250619.dist-info → zenml_nightly-0.83.0.dev20250622.dist-info}/RECORD +47 -41
  45. {zenml_nightly-0.83.0.dev20250619.dist-info → zenml_nightly-0.83.0.dev20250622.dist-info}/LICENSE +0 -0
  46. {zenml_nightly-0.83.0.dev20250619.dist-info → zenml_nightly-0.83.0.dev20250622.dist-info}/WHEEL +0 -0
  47. {zenml_nightly-0.83.0.dev20250619.dist-info → zenml_nightly-0.83.0.dev20250622.dist-info}/entry_points.txt +0 -0
@@ -22,15 +22,9 @@ To run this file locally, execute:
22
22
 
23
23
  import logging
24
24
  import os
25
- import threading
26
- import time
27
- from asyncio import Lock, Semaphore, TimeoutError, wait_for
28
25
  from asyncio.log import logger
29
- from contextvars import ContextVar
30
- from datetime import datetime, timedelta
31
26
  from genericpath import isfile
32
- from typing import Any, List, Optional, Set
33
- from uuid import uuid4
27
+ from typing import Any, List
34
28
 
35
29
  from anyio import to_thread
36
30
  from fastapi import FastAPI, HTTPException, Request
@@ -38,31 +32,25 @@ from fastapi.exceptions import RequestValidationError
38
32
  from fastapi.responses import ORJSONResponse
39
33
  from fastapi.staticfiles import StaticFiles
40
34
  from fastapi.templating import Jinja2Templates
41
- from starlette.middleware.base import (
42
- BaseHTTPMiddleware,
43
- RequestResponseEndpoint,
44
- )
45
- from starlette.middleware.cors import CORSMiddleware
46
35
  from starlette.responses import (
47
36
  FileResponse,
48
- JSONResponse,
49
37
  RedirectResponse,
50
- Response,
51
38
  )
52
- from starlette.types import ASGIApp
53
39
 
54
40
  import zenml
55
- from zenml.analytics import source_context
56
41
  from zenml.constants import (
57
42
  API,
58
- DEFAULT_ZENML_SERVER_REPORT_USER_ACTIVITY_TO_DB_SECONDS,
59
43
  HEALTH,
44
+ READY,
60
45
  )
61
- from zenml.enums import AuthScheme, SourceContextTypes
46
+ from zenml.enums import AuthScheme
62
47
  from zenml.models import ServerDeploymentType
63
- from zenml.utils.time_utils import utc_now
48
+ from zenml.service_connectors.service_connector_registry import (
49
+ service_connector_registry,
50
+ )
64
51
  from zenml.zen_server.cloud_utils import send_pro_workspace_status_update
65
52
  from zenml.zen_server.exceptions import error_detail
53
+ from zenml.zen_server.middleware import add_middlewares
66
54
  from zenml.zen_server.routers import (
67
55
  actions_endpoints,
68
56
  artifact_endpoint,
@@ -101,28 +89,25 @@ from zenml.zen_server.routers import (
101
89
  )
102
90
  from zenml.zen_server.secure_headers import (
103
91
  initialize_secure_headers,
104
- secure_headers,
105
92
  )
106
93
  from zenml.zen_server.utils import (
94
+ cleanup_request_manager,
107
95
  initialize_feature_gate,
108
96
  initialize_memcache,
109
97
  initialize_plugins,
110
98
  initialize_rbac,
99
+ initialize_request_manager,
111
100
  initialize_run_template_executor,
112
101
  initialize_workload_manager,
113
102
  initialize_zen_store,
114
- is_user_request,
115
103
  run_template_executor,
116
104
  server_config,
117
- zen_store,
105
+ start_event_loop_lag_monitor,
106
+ stop_event_loop_lag_monitor,
118
107
  )
119
108
 
120
109
  DASHBOARD_DIRECTORY = "dashboard"
121
110
 
122
- request_ids: ContextVar[Optional[str]] = ContextVar(
123
- "request_ids", default=None
124
- )
125
-
126
111
 
127
112
  def relative_path(rel: str) -> str:
128
113
  """Get the absolute path of a path relative to the ZenML server module.
@@ -143,12 +128,7 @@ app = FastAPI(
143
128
  default_response_class=ORJSONResponse,
144
129
  )
145
130
 
146
- # Initialize last_user_activity
147
- last_user_activity: datetime = utc_now()
148
- last_user_activity_reported: datetime = last_user_activity + timedelta(
149
- seconds=-DEFAULT_ZENML_SERVER_REPORT_USER_ACTIVITY_TO_DB_SECONDS
150
- )
151
- last_user_activity_lock = Lock()
131
+ add_middlewares(app)
152
132
 
153
133
 
154
134
  # Customize the default request validation handler that comes with FastAPI
@@ -169,373 +149,8 @@ def validation_exception_handler(
169
149
  return ORJSONResponse(error_detail(exc, ValueError), status_code=422)
170
150
 
171
151
 
172
- class RequestBodyLimit(BaseHTTPMiddleware):
173
- """Limits the size of the request body."""
174
-
175
- def __init__(self, app: ASGIApp, max_bytes: int) -> None:
176
- """Limits the size of the request body.
177
-
178
- Args:
179
- app: The FastAPI app.
180
- max_bytes: The maximum size of the request body.
181
- """
182
- super().__init__(app)
183
- self.max_bytes = max_bytes
184
-
185
- async def dispatch(
186
- self, request: Request, call_next: RequestResponseEndpoint
187
- ) -> Response:
188
- """Limits the size of the request body.
189
-
190
- Args:
191
- request: The incoming request.
192
- call_next: The next function to be called.
193
-
194
- Returns:
195
- The response to the request.
196
- """
197
- if content_length := request.headers.get("content-length"):
198
- if int(content_length) > self.max_bytes:
199
- return Response(status_code=413) # Request Entity Too Large
200
-
201
- try:
202
- return await call_next(request)
203
- except Exception:
204
- logger.exception("An error occurred while processing the request")
205
- return JSONResponse(
206
- status_code=500,
207
- content={"detail": "An unexpected error occurred."},
208
- )
209
-
210
-
211
- class RestrictFileUploadsMiddleware(BaseHTTPMiddleware):
212
- """Restrict file uploads to certain paths."""
213
-
214
- def __init__(self, app: FastAPI, allowed_paths: Set[str]):
215
- """Restrict file uploads to certain paths.
216
-
217
- Args:
218
- app: The FastAPI app.
219
- allowed_paths: The allowed paths.
220
- """
221
- super().__init__(app)
222
- self.allowed_paths = allowed_paths
223
-
224
- async def dispatch(
225
- self, request: Request, call_next: RequestResponseEndpoint
226
- ) -> Response:
227
- """Restrict file uploads to certain paths.
228
-
229
- Args:
230
- request: The incoming request.
231
- call_next: The next function to be called.
232
-
233
- Returns:
234
- The response to the request.
235
- """
236
- if request.method == "POST":
237
- content_type = request.headers.get("content-type", "")
238
- if (
239
- "multipart/form-data" in content_type
240
- and request.url.path not in self.allowed_paths
241
- ):
242
- return JSONResponse(
243
- status_code=403,
244
- content={
245
- "detail": "File uploads are not allowed on this endpoint."
246
- },
247
- )
248
-
249
- try:
250
- return await call_next(request)
251
- except Exception:
252
- logger.exception("An error occurred while processing the request")
253
- return JSONResponse(
254
- status_code=500,
255
- content={"detail": "An unexpected error occurred."},
256
- )
257
-
258
-
259
- ALLOWED_FOR_FILE_UPLOAD: Set[str] = set()
260
-
261
-
262
- @app.middleware("http")
263
- async def track_last_user_activity(request: Request, call_next: Any) -> Any:
264
- """A middleware to track last user activity.
265
-
266
- This middleware checks if the incoming request is a user request and
267
- updates the last activity timestamp if it is.
268
-
269
- Args:
270
- request: The incoming request object.
271
- call_next: A function that will receive the request as a parameter and
272
- pass it to the corresponding path operation.
273
-
274
- Returns:
275
- The response to the request.
276
- """
277
- global last_user_activity
278
- global last_user_activity_reported
279
- global last_user_activity_lock
280
-
281
- now = utc_now()
282
-
283
- try:
284
- if is_user_request(request):
285
- report_user_activity = False
286
- async with last_user_activity_lock:
287
- last_user_activity = now
288
- if (
289
- (now - last_user_activity_reported).total_seconds()
290
- > DEFAULT_ZENML_SERVER_REPORT_USER_ACTIVITY_TO_DB_SECONDS
291
- ):
292
- last_user_activity_reported = now
293
- report_user_activity = True
294
-
295
- if report_user_activity:
296
- zen_store()._update_last_user_activity_timestamp(
297
- last_user_activity=last_user_activity
298
- )
299
- except Exception as e:
300
- logger.debug(
301
- f"An unexpected error occurred while checking user activity: {e}"
302
- )
303
-
304
- try:
305
- return await call_next(request)
306
- except Exception:
307
- logger.exception("An error occurred while processing the request")
308
- return JSONResponse(
309
- status_code=500,
310
- content={"detail": "An unexpected error occurred."},
311
- )
312
-
313
-
314
- @app.middleware("http")
315
- async def infer_source_context(request: Request, call_next: Any) -> Any:
316
- """A middleware to track the source of an event.
317
-
318
- It extracts the source context from the header of incoming requests
319
- and applies it to the ZenML source context on the API side. This way, the
320
- outgoing analytics request can append it as an additional field.
321
-
322
- Args:
323
- request: the incoming request object.
324
- call_next: a function that will receive the request as a parameter and
325
- pass it to the corresponding path operation.
326
-
327
- Returns:
328
- the response to the request.
329
- """
330
- try:
331
- s = request.headers.get(
332
- source_context.name,
333
- default=SourceContextTypes.API.value,
334
- )
335
- source_context.set(SourceContextTypes(s))
336
- except Exception as e:
337
- logger.warning(
338
- f"An unexpected error occurred while getting the source "
339
- f"context: {e}"
340
- )
341
- source_context.set(SourceContextTypes.API)
342
-
343
- try:
344
- return await call_next(request)
345
- except Exception:
346
- logger.exception("An error occurred while processing the request")
347
- return JSONResponse(
348
- status_code=500,
349
- content={"detail": "An unexpected error occurred."},
350
- )
351
-
352
-
353
- request_semaphore = Semaphore(server_config().thread_pool_size)
354
-
355
-
356
- @app.middleware("http")
357
- async def prevent_read_timeout(request: Request, call_next: Any) -> Any:
358
- """Prevent read timeout client errors.
359
-
360
- Args:
361
- request: The incoming request.
362
- call_next: The next function to be called.
363
-
364
- Returns:
365
- The response to the request.
366
- """
367
- # Only process the REST API requests because these are the ones that
368
- # take the most time to complete.
369
- if not request.url.path.startswith(API):
370
- return await call_next(request)
371
-
372
- server_request_timeout = server_config().server_request_timeout
373
-
374
- active_threads = threading.active_count()
375
- request_id = request_ids.get()
376
-
377
- client_ip = request.client.host if request.client else "unknown"
378
- method = request.method
379
- url_path = request.url.path
380
-
381
- logger.debug(
382
- f"[{request_id}] API STATS - {method} {url_path} from {client_ip} "
383
- f"QUEUED [ "
384
- f"threads: {active_threads} "
385
- f"]"
386
- )
387
-
388
- start_time = time.time()
389
-
390
- try:
391
- # Here we wait until a worker thread is available to process the
392
- # request with a timeout value that is set to be lower than the
393
- # what the client is willing to wait for (i.e. lower than the
394
- # client's HTTP request timeout). The rationale is that we want to
395
- # respond to the client before it times out and decides to retry the
396
- # request (which would overwhelm the server).
397
- await wait_for(
398
- request_semaphore.acquire(),
399
- timeout=server_request_timeout,
400
- )
401
- except TimeoutError:
402
- end_time = time.time()
403
- duration = (end_time - start_time) * 1000
404
- active_threads = threading.active_count()
405
-
406
- logger.debug(
407
- f"[{request_id}] API STATS - {method} {url_path} from {client_ip} "
408
- f"THROTTLED after {duration:.2f}ms [ "
409
- f"threads: {active_threads} "
410
- f"]"
411
- )
412
-
413
- # We return a 429 error, basically telling the client to slow down.
414
- # For the client, the 429 error is more meaningful than a ReadTimeout
415
- # error, because it also tells the client two additional things:
416
- #
417
- # 1. The server is alive.
418
- # 2. The server hasn't processed the request, so even if the request
419
- # is not idempotent, it's safe to retry it.
420
- return JSONResponse(
421
- {"error": "Server too busy. Please try again later."},
422
- status_code=429,
423
- )
424
-
425
- duration = (time.time() - start_time) * 1000
426
- active_threads = threading.active_count()
427
-
428
- logger.debug(
429
- f"[{request_id}] API STATS - {method} {url_path} from {client_ip} "
430
- f"ACCEPTED after {duration:.2f}ms [ "
431
- f"threads: {active_threads} "
432
- f"]"
433
- )
434
-
435
- try:
436
- return await call_next(request)
437
- finally:
438
- request_semaphore.release()
439
-
440
-
441
- @app.middleware("http")
442
- async def log_requests(request: Request, call_next: Any) -> Any:
443
- """Log requests to the ZenML server.
444
-
445
- Args:
446
- request: The incoming request object.
447
- call_next: A function that will receive the request as a parameter and
448
- pass it to the corresponding path operation.
449
-
450
- Returns:
451
- The response to the request.
452
- """
453
- if not logger.isEnabledFor(logging.DEBUG):
454
- return await call_next(request)
455
-
456
- # Get active threads count
457
- active_threads = threading.active_count()
458
-
459
- request_id = request.headers.get("X-Request-ID", str(uuid4())[:8])
460
- # Detect if the request comes from Python, Web UI or something else
461
- if source := request.headers.get("User-Agent"):
462
- source = source.split("/")[0]
463
- request_id = f"{request_id}/{source}"
464
-
465
- request_ids.set(request_id)
466
- client_ip = request.client.host if request.client else "unknown"
467
- method = request.method
468
- url_path = request.url.path
469
-
470
- logger.debug(
471
- f"[{request_id}] API STATS - {method} {url_path} from {client_ip} "
472
- f"RECEIVED [ "
473
- f"threads: {active_threads} "
474
- f"]"
475
- )
476
-
477
- start_time = time.time()
478
- response = await call_next(request)
479
- duration = (time.time() - start_time) * 1000
480
- status_code = response.status_code
481
-
482
- logger.debug(
483
- f"[{request_id}] API STATS - {status_code} {method} {url_path} from "
484
- f"{client_ip} took {duration:.2f}ms [ "
485
- f"threads: {active_threads} "
486
- f"]"
487
- )
488
- return response
489
-
490
-
491
- app.add_middleware(
492
- CORSMiddleware,
493
- allow_origins=server_config().cors_allow_origins,
494
- allow_credentials=True,
495
- allow_methods=["*"],
496
- allow_headers=["*"],
497
- )
498
-
499
- app.add_middleware(
500
- RequestBodyLimit, max_bytes=server_config().max_request_body_size_in_bytes
501
- )
502
- app.add_middleware(
503
- RestrictFileUploadsMiddleware, allowed_paths=ALLOWED_FOR_FILE_UPLOAD
504
- )
505
-
506
-
507
- @app.middleware("http")
508
- async def set_secure_headers(request: Request, call_next: Any) -> Any:
509
- """Middleware to set secure headers.
510
-
511
- Args:
512
- request: The incoming request.
513
- call_next: The next function to be called.
514
-
515
- Returns:
516
- The response with secure headers set.
517
- """
518
- try:
519
- response = await call_next(request)
520
- except Exception:
521
- logger.exception("An error occurred while processing the request")
522
- response = JSONResponse(
523
- status_code=500,
524
- content={"detail": "An unexpected error occurred."},
525
- )
526
-
527
- # If the request is for the openAPI docs, don't set secure headers
528
- if request.url.path.startswith("/docs") or request.url.path.startswith(
529
- "/redoc"
530
- ):
531
- return response
532
-
533
- secure_headers().framework.fastapi(response)
534
- return response
535
-
536
-
537
152
  @app.on_event("startup")
538
- def initialize() -> None:
153
+ async def initialize() -> None:
539
154
  """Initialize the ZenML server."""
540
155
  cfg = server_config()
541
156
  # Set the maximum number of worker threads
@@ -544,7 +159,9 @@ def initialize() -> None:
544
159
  )
545
160
  # IMPORTANT: these need to be run before the fastapi app starts, to avoid
546
161
  # race conditions
162
+ await initialize_request_manager()
547
163
  initialize_zen_store()
164
+ service_connector_registry.register_builtin_service_connectors()
548
165
  initialize_rbac()
549
166
  initialize_feature_gate()
550
167
  initialize_workload_manager()
@@ -557,11 +174,17 @@ def initialize() -> None:
557
174
  # ZenML server is running or to update the version and server URL.
558
175
  send_pro_workspace_status_update()
559
176
 
177
+ if logger.isEnabledFor(logging.DEBUG):
178
+ start_event_loop_lag_monitor()
179
+
560
180
 
561
181
  @app.on_event("shutdown")
562
- def shutdown() -> None:
182
+ async def shutdown() -> None:
563
183
  """Shutdown the ZenML server."""
184
+ if logger.isEnabledFor(logging.DEBUG):
185
+ stop_event_loop_lag_monitor()
564
186
  run_template_executor().shutdown(wait=True)
187
+ await cleanup_request_manager()
565
188
 
566
189
 
567
190
  DASHBOARD_REDIRECT_URL = None
@@ -595,6 +218,18 @@ async def health() -> str:
595
218
  return "OK"
596
219
 
597
220
 
221
+ # Basic Ready Endpoint
222
+ @app.head(READY, include_in_schema=False)
223
+ @app.get(READY)
224
+ async def ready() -> str:
225
+ """Get readiness status of the server.
226
+
227
+ Returns:
228
+ String representing the readiness status of the server.
229
+ """
230
+ return "OK"
231
+
232
+
598
233
  templates = Jinja2Templates(directory=relative_path(DASHBOARD_DIRECTORY))
599
234
 
600
235
 
@@ -0,0 +1,138 @@
1
+ """Split up step configurations [3d7e39f3ac92].
2
+
3
+ Revision ID: 3d7e39f3ac92
4
+ Revises: 857843db1bcf
5
+ Create Date: 2025-06-17 17:45:31.702617
6
+
7
+ """
8
+
9
+ import json
10
+ import uuid
11
+
12
+ import sqlalchemy as sa
13
+ import sqlmodel
14
+ from alembic import op
15
+ from sqlalchemy.dialects import mysql
16
+
17
+ from zenml.utils.time_utils import utc_now
18
+
19
+ # revision identifiers, used by Alembic.
20
+ revision = "3d7e39f3ac92"
21
+ down_revision = "857843db1bcf"
22
+ branch_labels = None
23
+ depends_on = None
24
+
25
+
26
+ def upgrade() -> None:
27
+ """Upgrade database schema and/or data, creating a new revision."""
28
+ # ### commands auto generated by Alembic - please adjust! ###
29
+ op.create_table(
30
+ "step_configuration",
31
+ sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False),
32
+ sa.Column("created", sa.DateTime(), nullable=False),
33
+ sa.Column("updated", sa.DateTime(), nullable=False),
34
+ sa.Column("index", sa.Integer(), nullable=False),
35
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
36
+ sa.Column(
37
+ "config",
38
+ sa.String(length=16777215).with_variant(mysql.MEDIUMTEXT, "mysql"),
39
+ nullable=False,
40
+ ),
41
+ sa.Column(
42
+ "deployment_id", sqlmodel.sql.sqltypes.GUID(), nullable=False
43
+ ),
44
+ sa.ForeignKeyConstraint(
45
+ ["deployment_id"],
46
+ ["pipeline_deployment.id"],
47
+ name="fk_step_configuration_deployment_id_pipeline_deployment",
48
+ ondelete="CASCADE",
49
+ ),
50
+ sa.PrimaryKeyConstraint("id"),
51
+ sa.UniqueConstraint(
52
+ "deployment_id", "name", name="unique_step_name_for_deployment"
53
+ ),
54
+ )
55
+ with op.batch_alter_table("pipeline_deployment", schema=None) as batch_op:
56
+ batch_op.add_column(
57
+ sa.Column("step_count", sa.Integer(), nullable=True)
58
+ )
59
+
60
+ # Migrate existing step configurations
61
+ connection = op.get_bind()
62
+ meta = sa.MetaData()
63
+ meta.reflect(
64
+ bind=connection, only=("pipeline_deployment", "step_configuration")
65
+ )
66
+ pipeline_deployment_table = sa.Table("pipeline_deployment", meta)
67
+ step_configuration_table = sa.Table("step_configuration", meta)
68
+
69
+ step_configurations_to_insert = []
70
+ deployment_updates = []
71
+
72
+ for deployment_id, step_configurations_json in connection.execute(
73
+ sa.select(
74
+ pipeline_deployment_table.c.id,
75
+ pipeline_deployment_table.c.step_configurations,
76
+ )
77
+ ):
78
+ step_configurations = json.loads(step_configurations_json)
79
+
80
+ step_count = len(step_configurations)
81
+ deployment_updates.append(
82
+ {
83
+ "id_": deployment_id,
84
+ "step_count": step_count,
85
+ }
86
+ )
87
+
88
+ for index, (step_name, step_config) in enumerate(
89
+ step_configurations.items()
90
+ ):
91
+ now = utc_now()
92
+ step_configurations_to_insert.append(
93
+ {
94
+ "id": str(uuid.uuid4()).replace("-", ""),
95
+ "created": now,
96
+ "updated": now,
97
+ "index": index,
98
+ "name": step_name,
99
+ "config": json.dumps(step_config),
100
+ "deployment_id": deployment_id,
101
+ }
102
+ )
103
+
104
+ op.bulk_insert(
105
+ step_configuration_table, rows=step_configurations_to_insert
106
+ )
107
+ if deployment_updates:
108
+ connection.execute(
109
+ sa.update(pipeline_deployment_table).where(
110
+ pipeline_deployment_table.c.id == sa.bindparam("id_")
111
+ ),
112
+ deployment_updates,
113
+ )
114
+
115
+ with op.batch_alter_table("pipeline_deployment", schema=None) as batch_op:
116
+ batch_op.alter_column(
117
+ "step_count", existing_type=sa.Integer(), nullable=False
118
+ )
119
+ batch_op.drop_column("step_configurations")
120
+
121
+ # ### end Alembic commands ###
122
+
123
+
124
+ def downgrade() -> None:
125
+ """Downgrade database schema and/or data back to the previous revision."""
126
+ # ### commands auto generated by Alembic - please adjust! ###
127
+ with op.batch_alter_table("pipeline_deployment", schema=None) as batch_op:
128
+ batch_op.add_column(
129
+ sa.Column(
130
+ "step_configurations",
131
+ sa.VARCHAR(length=16777215),
132
+ nullable=False,
133
+ )
134
+ )
135
+ batch_op.drop_column("step_count")
136
+
137
+ op.drop_table("step_configuration")
138
+ # ### end Alembic commands ###