dirigent-core 0.18.0__tar.gz → 0.18.2__tar.gz
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.
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/PKG-INFO +4 -4
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/pyproject.toml +4 -4
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/pyproject.toml.orig +4 -4
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/env.py +12 -10
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/versions/0001_baseline_schema.py +11 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alerting.py +75 -25
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/definition.py +6 -1
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/runs.py +2 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/messages.py +15 -2
- dirigent_core-0.18.2/src/dirigent_core/migrations.py +203 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/models.py +15 -0
- dirigent_core-0.18.0/src/dirigent_core/migrations.py +0 -98
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/LICENSE +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/README.md +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/__init__.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/alembic/script.py.mako +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/artifacts.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/auth.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/blockdocs.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/config.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/configdocs.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/database.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/directory.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/documents.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/documentschema.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/__init__.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/claim.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/context.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/executor.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/failure.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/recovery.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/references.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/services.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/engine/state.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/errors.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/examples.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/ids.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/logging.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/pipelines.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/plugins.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/protocol.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/py.typed +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/ratelimit.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/registry.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/reporting.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/retention.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/scheduler.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/schemas.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/secrets.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/storage.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/telemetry.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/trigger_documents.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/__init__.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/backfill.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/materialize.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/schedules.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/triggers/webhooks.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/types.py +0 -0
- {dirigent_core-0.18.0 → dirigent_core-0.18.2}/src/dirigent_core/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirigent-core
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.2
|
|
4
4
|
Summary: Dirigent engine core: schema, configuration, and plugin host.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,9 +12,9 @@ Requires-Dist: argon2-cffi>=25.1.0
|
|
|
12
12
|
Requires-Dist: asyncpg>=0.31.0
|
|
13
13
|
Requires-Dist: cronsim>=2.7
|
|
14
14
|
Requires-Dist: cryptography>=46.0.5
|
|
15
|
-
Requires-Dist: dirigent-client==0.18.
|
|
16
|
-
Requires-Dist: dirigent-common==0.18.
|
|
17
|
-
Requires-Dist: dirigent-plugin==0.18.
|
|
15
|
+
Requires-Dist: dirigent-client==0.18.2
|
|
16
|
+
Requires-Dist: dirigent-common==0.18.2
|
|
17
|
+
Requires-Dist: dirigent-plugin==0.18.2
|
|
18
18
|
Requires-Dist: jsonschema>=4.26.0
|
|
19
19
|
Requires-Dist: opentelemetry-api>=1.44.0
|
|
20
20
|
Requires-Dist: pydantic-settings>=2.15.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.18.
|
|
3
|
+
version = "0.18.2"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,9 +17,9 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.18.
|
|
21
|
-
"dirigent-common==0.18.
|
|
22
|
-
"dirigent-plugin==0.18.
|
|
20
|
+
"dirigent-client==0.18.2",
|
|
21
|
+
"dirigent-common==0.18.2",
|
|
22
|
+
"dirigent-plugin==0.18.2",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dirigent-core"
|
|
3
|
-
version = "0.18.
|
|
3
|
+
version = "0.18.2"
|
|
4
4
|
description = "Dirigent engine core: schema, configuration, and plugin host."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -17,9 +17,9 @@ dependencies = [
|
|
|
17
17
|
"asyncpg>=0.31.0",
|
|
18
18
|
"cronsim>=2.7",
|
|
19
19
|
"cryptography>=46.0.5",
|
|
20
|
-
"dirigent-client==0.18.
|
|
21
|
-
"dirigent-common==0.18.
|
|
22
|
-
"dirigent-plugin==0.18.
|
|
20
|
+
"dirigent-client==0.18.2",
|
|
21
|
+
"dirigent-common==0.18.2",
|
|
22
|
+
"dirigent-plugin==0.18.2",
|
|
23
23
|
"jsonschema>=4.26.0",
|
|
24
24
|
"opentelemetry-api>=1.44.0",
|
|
25
25
|
"pydantic-settings>=2.15.0",
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
"""Alembic environment: one migration history that runs on both PostgreSQL and SQLite."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
from typing import Any
|
|
5
4
|
|
|
6
5
|
from alembic import context
|
|
7
6
|
from sqlalchemy import Connection
|
|
8
|
-
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
9
|
-
from sqlalchemy.pool import NullPool
|
|
10
7
|
|
|
11
8
|
from dirigent_core.config import get_settings
|
|
9
|
+
from dirigent_core.database import create_engine
|
|
12
10
|
from dirigent_core.models import Base
|
|
13
11
|
|
|
14
12
|
config = context.config
|
|
@@ -47,13 +45,17 @@ def do_run_migrations(connection: Connection) -> None:
|
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
async def run_async_migrations() -> None:
|
|
50
|
-
"""Open the
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
"""Open the engine on the URL to migrate and drive the migrations through it.
|
|
49
|
+
|
|
50
|
+
The engine is the instance's own, so this path opens SQLite with the pragmas every other
|
|
51
|
+
connection in the process is configured with.
|
|
52
|
+
"""
|
|
53
|
+
engine = create_engine(get_settings().model_copy(update={"database_url": database_url()}))
|
|
54
|
+
try:
|
|
55
|
+
async with engine.connect() as connection:
|
|
56
|
+
await connection.run_sync(do_run_migrations)
|
|
57
|
+
finally:
|
|
58
|
+
await engine.dispose()
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
def run_migrations_online() -> None:
|
|
@@ -96,6 +96,12 @@ def upgrade() -> None:
|
|
|
96
96
|
sa.Column("name", sa.String(length=200), nullable=True),
|
|
97
97
|
sa.Column("description", sa.Text(), nullable=True),
|
|
98
98
|
sa.Column("tags", sa.JSON().with_variant(postgresql.JSONB(astext_type=Text()), "postgresql"), nullable=False),
|
|
99
|
+
sa.Column(
|
|
100
|
+
"importance",
|
|
101
|
+
sa.Enum("routine", "normal", "critical", name="importance", native_enum=False, length=32),
|
|
102
|
+
server_default="normal",
|
|
103
|
+
nullable=False,
|
|
104
|
+
),
|
|
99
105
|
sa.Column("active", sa.Boolean(), server_default=sa.true(), nullable=False),
|
|
100
106
|
sa.Column("current_version", sa.Integer(), nullable=True),
|
|
101
107
|
sa.Column(
|
|
@@ -208,6 +214,11 @@ def upgrade() -> None:
|
|
|
208
214
|
"scope", sa.Enum("global", "pipeline", name="alert_scope", native_enum=False, length=32), nullable=False
|
|
209
215
|
),
|
|
210
216
|
sa.Column("pipeline_id", sa.Uuid(), nullable=True),
|
|
217
|
+
sa.Column(
|
|
218
|
+
"importance",
|
|
219
|
+
sa.Enum("routine", "normal", "critical", name="importance", native_enum=False, length=32),
|
|
220
|
+
nullable=True,
|
|
221
|
+
),
|
|
211
222
|
sa.Column("notifier", sa.String(length=100), nullable=False),
|
|
212
223
|
sa.Column("connection_id", sa.Uuid(), nullable=True),
|
|
213
224
|
sa.Column("template", sa.Text(), nullable=True),
|
|
@@ -17,7 +17,16 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
|
17
17
|
from sqlalchemy.exc import IntegrityError
|
|
18
18
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
19
19
|
|
|
20
|
-
from dirigent_client.enums import
|
|
20
|
+
from dirigent_client.enums import (
|
|
21
|
+
IMPORTANCE_RANK,
|
|
22
|
+
AlertEvent,
|
|
23
|
+
AlertScope,
|
|
24
|
+
Importance,
|
|
25
|
+
LogLevel,
|
|
26
|
+
NotificationStatus,
|
|
27
|
+
RunStatus,
|
|
28
|
+
)
|
|
29
|
+
from dirigent_client.schemas.alerts import LOG_NOTIFIER
|
|
21
30
|
from dirigent_common import (
|
|
22
31
|
EntityName,
|
|
23
32
|
JsonMap,
|
|
@@ -42,7 +51,7 @@ from dirigent_core.messages import (
|
|
|
42
51
|
NOTIFICATION_IN_FLIGHT,
|
|
43
52
|
NOTIFIER_NOT_ON_WORKER,
|
|
44
53
|
SCOPE_NEEDS_PIPELINE,
|
|
45
|
-
|
|
54
|
+
TARGET_HAS_NO_NOTIFIER,
|
|
46
55
|
)
|
|
47
56
|
from dirigent_core.models import (
|
|
48
57
|
AlertRule,
|
|
@@ -101,10 +110,11 @@ class AlertRuleRequest(BaseModel):
|
|
|
101
110
|
name: str | None = None
|
|
102
111
|
description: str | None = None
|
|
103
112
|
event: AlertEvent
|
|
104
|
-
notifier: str
|
|
105
113
|
scope: AlertScope = AlertScope.GLOBAL
|
|
106
114
|
pipeline: str | None = None
|
|
115
|
+
importance: Importance | None = None
|
|
107
116
|
connection: str | None = None
|
|
117
|
+
"""The connection this rule delivers through, whose kind names the sender; none is the log."""
|
|
108
118
|
template: str | None = None
|
|
109
119
|
body: str | None = None
|
|
110
120
|
throttle: timedelta = timedelta(0)
|
|
@@ -173,15 +183,12 @@ async def list_rules(
|
|
|
173
183
|
|
|
174
184
|
|
|
175
185
|
async def create_rule(session: AsyncSession, services: EngineServices, request: AlertRuleRequest) -> AlertRule:
|
|
176
|
-
"""Declare an alert rule, refusing a
|
|
177
|
-
if request.notifier not in services.host.notifiers:
|
|
178
|
-
installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
|
|
179
|
-
raise AlertError(UNKNOWN_NOTIFIER, notifier=repr(request.notifier), installed=installed)
|
|
186
|
+
"""Declare an alert rule, refusing a target or a scope this instance cannot honour."""
|
|
180
187
|
if await find_rule(session, request.code) is not None:
|
|
181
188
|
raise AlertError(DUPLICATE_RULE, code=repr(request.code))
|
|
182
189
|
check_templates(template=request.template, body=request.body)
|
|
183
190
|
pipeline_id = await _scope_pipeline(session, request)
|
|
184
|
-
connection_id = await
|
|
191
|
+
notifier, connection_id = await _target(session, services, request.connection)
|
|
185
192
|
rule = AlertRule(
|
|
186
193
|
code=request.code,
|
|
187
194
|
name=request.name,
|
|
@@ -189,7 +196,8 @@ async def create_rule(session: AsyncSession, services: EngineServices, request:
|
|
|
189
196
|
event=request.event,
|
|
190
197
|
scope=request.scope,
|
|
191
198
|
pipeline_id=pipeline_id,
|
|
192
|
-
|
|
199
|
+
importance=request.importance,
|
|
200
|
+
notifier=notifier,
|
|
193
201
|
connection_id=connection_id,
|
|
194
202
|
template=request.template,
|
|
195
203
|
body=request.body,
|
|
@@ -225,13 +233,31 @@ async def _scope_pipeline(session: AsyncSession, request: AlertRuleRequest) -> U
|
|
|
225
233
|
return pipeline.id
|
|
226
234
|
|
|
227
235
|
|
|
228
|
-
async def
|
|
229
|
-
"""
|
|
236
|
+
async def _connection(session: AsyncSession, code: str) -> Connection:
|
|
237
|
+
"""Read one connection by code, refusing a code this instance does not hold."""
|
|
230
238
|
found = await session.execute(sa.select(Connection).where(Connection.code == code))
|
|
231
239
|
connection = found.scalar_one_or_none()
|
|
232
240
|
if connection is None:
|
|
233
241
|
raise AlertError(ALERT_UNKNOWN_CONNECTION, code=repr(code))
|
|
234
|
-
return connection
|
|
242
|
+
return connection
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def _target(session: AsyncSession, services: EngineServices, code: str | None) -> tuple[str, UUID | None]:
|
|
246
|
+
"""Resolve the one target named into the sender that delivers it and what it opens.
|
|
247
|
+
|
|
248
|
+
A target is a connection, and the notifier is that connection's kind; naming nothing is
|
|
249
|
+
the process log. A kind no installed notifier answers to is refused here rather than
|
|
250
|
+
stored, because the row would otherwise hold a sender no worker can dispatch on.
|
|
251
|
+
"""
|
|
252
|
+
if code is None:
|
|
253
|
+
return LOG_NOTIFIER, None
|
|
254
|
+
connection = await _connection(session, code)
|
|
255
|
+
if connection.kind not in services.host.notifiers:
|
|
256
|
+
installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
|
|
257
|
+
raise AlertError(
|
|
258
|
+
TARGET_HAS_NO_NOTIFIER, code=repr(connection.code), kind=repr(connection.kind), installed=installed
|
|
259
|
+
)
|
|
260
|
+
return connection.kind, connection.id
|
|
235
261
|
|
|
236
262
|
|
|
237
263
|
async def delete_rule(session: AsyncSession, rule: AlertRule) -> None:
|
|
@@ -242,11 +268,23 @@ async def delete_rule(session: AsyncSession, rule: AlertRule) -> None:
|
|
|
242
268
|
_logger.info("alert rule deleted", rule=code)
|
|
243
269
|
|
|
244
270
|
|
|
245
|
-
|
|
271
|
+
def _satisfied_levels(importance: Importance) -> list[Importance]:
|
|
272
|
+
"""The floors a pipeline of this importance clears: its own and every lesser one."""
|
|
273
|
+
reached = IMPORTANCE_RANK[importance]
|
|
274
|
+
return [level for level, rank in IMPORTANCE_RANK.items() if rank <= reached]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def matching_rules(
|
|
278
|
+
session: AsyncSession, event: AlertEvent, pipeline_id: UUID, importance: Importance
|
|
279
|
+
) -> list[AlertRule]:
|
|
246
280
|
"""Find the live rules that want to hear about one event, throttled or not.
|
|
247
281
|
|
|
248
282
|
A paused rule matches nothing. Pausing is instance state an operator sets on the row, so
|
|
249
283
|
it is read here rather than folded into ``active``, which is what the rule itself declares.
|
|
284
|
+
|
|
285
|
+
A rule that names an importance wants only the pipelines that carry at least that much;
|
|
286
|
+
one that names none wants every pipeline, so a rule that pages the critical failures and
|
|
287
|
+
a rule that logs everything both raise on the same run.
|
|
250
288
|
"""
|
|
251
289
|
rows = await session.execute(
|
|
252
290
|
sa.select(AlertRule).where(
|
|
@@ -254,6 +292,7 @@ async def matching_rules(session: AsyncSession, event: AlertEvent, pipeline_id:
|
|
|
254
292
|
AlertRule.active.is_(True),
|
|
255
293
|
AlertRule.paused.is_(False),
|
|
256
294
|
sa.or_(AlertRule.scope == AlertScope.GLOBAL, AlertRule.pipeline_id == pipeline_id),
|
|
295
|
+
sa.or_(AlertRule.importance.is_(None), AlertRule.importance.in_(_satisfied_levels(importance))),
|
|
257
296
|
)
|
|
258
297
|
)
|
|
259
298
|
return list(rows.scalars())
|
|
@@ -268,14 +307,17 @@ async def set_paused(session: AsyncSession, rule: AlertRule, *, paused: bool) ->
|
|
|
268
307
|
|
|
269
308
|
|
|
270
309
|
#: What a PATCH may write on a rule, and nothing else on the row.
|
|
271
|
-
UPDATABLE: Final = ("paused", "template", "body")
|
|
310
|
+
UPDATABLE: Final = ("paused", "template", "body", "importance", "connection")
|
|
272
311
|
|
|
273
312
|
|
|
274
|
-
async def update_rule(
|
|
313
|
+
async def update_rule(
|
|
314
|
+
session: AsyncSession, services: EngineServices, rule: AlertRule, changes: Mapping[str, object]
|
|
315
|
+
) -> AlertRule:
|
|
275
316
|
"""Write the fields a PATCH named on a rule, leaving every field it did not name.
|
|
276
317
|
|
|
277
|
-
A subject or a body is compiled here as it is at creation,
|
|
278
|
-
holds a template that renders
|
|
318
|
+
A subject or a body is compiled here as it is at creation, and a target is resolved here as
|
|
319
|
+
it is at creation, so a rule on the row always holds a template that renders and a sender a
|
|
320
|
+
worker can dispatch on.
|
|
279
321
|
"""
|
|
280
322
|
named = {name: value for name, value in changes.items() if name in UPDATABLE}
|
|
281
323
|
if "template" in named or "body" in named:
|
|
@@ -287,6 +329,16 @@ async def update_rule(session: AsyncSession, rule: AlertRule, changes: Mapping[s
|
|
|
287
329
|
rule.body = cast("str | None", named.get("body", rule.body))
|
|
288
330
|
await session.flush()
|
|
289
331
|
_logger.info("alert rule retemplated", rule=rule.code)
|
|
332
|
+
if "importance" in named:
|
|
333
|
+
floor = cast("Importance | None", named["importance"])
|
|
334
|
+
rule.importance = floor
|
|
335
|
+
await session.flush()
|
|
336
|
+
_logger.info("alert rule reweighted", rule=rule.code, importance=floor.value if floor else None)
|
|
337
|
+
if "connection" in named:
|
|
338
|
+
code = cast("str | None", named["connection"])
|
|
339
|
+
rule.notifier, rule.connection_id = await _target(session, services, code)
|
|
340
|
+
await session.flush()
|
|
341
|
+
_logger.info("alert rule retargeted", rule=rule.code, notifier=rule.notifier, connection=code)
|
|
290
342
|
if "paused" in named:
|
|
291
343
|
await set_paused(session, rule, paused=bool(named["paused"]))
|
|
292
344
|
return rule
|
|
@@ -386,7 +438,7 @@ async def _queue_for_run(
|
|
|
386
438
|
pipeline = await session.get(Pipeline, run.pipeline_id)
|
|
387
439
|
if pipeline is None: # pragma: no cover - the foreign key makes this unreachable
|
|
388
440
|
return []
|
|
389
|
-
rules = await matching_rules(session, event, run.pipeline_id)
|
|
441
|
+
rules = await matching_rules(session, event, run.pipeline_id, pipeline.importance)
|
|
390
442
|
if not rules:
|
|
391
443
|
return []
|
|
392
444
|
report_url = report_url_for(services.settings.alert_base_url, report_artifact_id)
|
|
@@ -633,26 +685,24 @@ async def queue_test_message(
|
|
|
633
685
|
session: AsyncSession,
|
|
634
686
|
services: EngineServices,
|
|
635
687
|
*,
|
|
636
|
-
notifier: str,
|
|
637
688
|
connection: str | None = None,
|
|
638
689
|
subject: str = "dirigent test alert",
|
|
639
690
|
body: str = "This is a test message sent through the notifier surface.",
|
|
640
691
|
) -> Notification:
|
|
641
692
|
"""Queue one unattached message, which is what ``dg alerts test`` sends.
|
|
642
693
|
|
|
643
|
-
The
|
|
644
|
-
|
|
694
|
+
The message names one target the way a rule does, and the sender follows from that
|
|
695
|
+
connection's kind. The subject and the body are rendered as a rule's are, over a stand-in
|
|
696
|
+
context, so a template can be tried out before it is written onto a rule.
|
|
645
697
|
"""
|
|
646
|
-
|
|
647
|
-
installed = ", ".join(sorted(services.host.notifiers)) or "none are installed"
|
|
648
|
-
raise AlertError(UNKNOWN_NOTIFIER, notifier=repr(notifier), installed=installed)
|
|
698
|
+
notifier, connection_id = await _target(session, services, connection)
|
|
649
699
|
context: JsonMap = {"run": {"pipeline": "(test)", "status": "succeeded"}, "report": None}
|
|
650
700
|
notification = Notification(
|
|
651
701
|
alert_rule_id=None,
|
|
652
702
|
run_id=None,
|
|
653
703
|
event=AlertEvent.RUN_SUCCEEDED,
|
|
654
704
|
notifier=notifier,
|
|
655
|
-
connection_id=
|
|
705
|
+
connection_id=connection_id,
|
|
656
706
|
subject=_one_line(_or_literal(subject, context, max_bytes=SUBJECT_RENDER_CAP)),
|
|
657
707
|
body=_or_literal(body, context, max_bytes=int(services.settings.report_max_size)),
|
|
658
708
|
context=context,
|
|
@@ -9,7 +9,7 @@ from jsonschema import Draft202012Validator, FormatChecker
|
|
|
9
9
|
from jsonschema import ValidationError as SchemaValidationError
|
|
10
10
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
|
|
11
11
|
|
|
12
|
-
from dirigent_client.enums import RunPriority
|
|
12
|
+
from dirigent_client.enums import Importance, RunPriority
|
|
13
13
|
from dirigent_client.schemas import Requirements
|
|
14
14
|
from dirigent_common import TEMPLATE_MEDIA_TYPE, EntityName, JsonMap, StepName, TemplateError, compile_template
|
|
15
15
|
from dirigent_common.durations import Duration
|
|
@@ -314,6 +314,11 @@ class PipelineDefinition(BaseModel):
|
|
|
314
314
|
|
|
315
315
|
A trigger may override it for what it fires, and an ad hoc run may override it again."""
|
|
316
316
|
|
|
317
|
+
importance: Importance = Importance.NORMAL
|
|
318
|
+
"""How much this pipeline matters, which is not when its work is claimed; priority says that.
|
|
319
|
+
|
|
320
|
+
An alert rule may name the least importance a pipeline must have before it fires."""
|
|
321
|
+
|
|
317
322
|
params: JsonMap = Field(default_factory=lambda: dict(EMPTY_PARAMS_SCHEMA))
|
|
318
323
|
steps: dict[StepName, StepDefinition] = Field(min_length=1)
|
|
319
324
|
triggers: TriggerSpecs = Field(default_factory=TriggerSpecs)
|
|
@@ -166,6 +166,7 @@ async def _save_pipeline_once(
|
|
|
166
166
|
name=definition.name,
|
|
167
167
|
description=description or definition.description,
|
|
168
168
|
tags=list(definition.tags),
|
|
169
|
+
importance=definition.importance,
|
|
169
170
|
)
|
|
170
171
|
session.add(pipeline)
|
|
171
172
|
await session.flush()
|
|
@@ -176,6 +177,7 @@ async def _save_pipeline_once(
|
|
|
176
177
|
pipeline.name = definition.name
|
|
177
178
|
pipeline.description = description or definition.description
|
|
178
179
|
pipeline.tags = list(definition.tags)
|
|
180
|
+
pipeline.importance = definition.importance
|
|
179
181
|
recorded = provenance or Provenance()
|
|
180
182
|
next_version = (pipeline.current_version or 0) + 1
|
|
181
183
|
version = PipelineVersion(
|
|
@@ -6,6 +6,16 @@ renders are what another language, a log reader and a test all hold on to.
|
|
|
6
6
|
|
|
7
7
|
from dirigent_common import Catalogue, Message
|
|
8
8
|
|
|
9
|
+
DATABASE = Catalogue("database")
|
|
10
|
+
|
|
11
|
+
SCHEMA_STALE = DATABASE.define(
|
|
12
|
+
"schema_stale",
|
|
13
|
+
"the database at {where} was written by a different dirigent: {differences} difference(s), "
|
|
14
|
+
"first {first}; before 1.0 the schema is not migrated, so start from an empty state "
|
|
15
|
+
"(`dg dev --wipe-state`, or delete the state directory) or point at a database this version created",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
9
19
|
AUTH = Catalogue("auth")
|
|
10
20
|
|
|
11
21
|
WEAK_PASSWORD = AUTH.define("weak_password", "a password must be at least {minimum} characters")
|
|
@@ -488,8 +498,6 @@ REQUIRED_UNMAPPED = WEBHOOK.define(
|
|
|
488
498
|
|
|
489
499
|
ALERT = Catalogue("alert")
|
|
490
500
|
|
|
491
|
-
UNKNOWN_NOTIFIER = ALERT.define("unknown_notifier", "no notifier {notifier} is installed ({installed})")
|
|
492
|
-
|
|
493
501
|
DUPLICATE_RULE = ALERT.define("duplicate_rule", "an alert rule coded {code} already exists")
|
|
494
502
|
|
|
495
503
|
BAD_ALERT_TEMPLATE = ALERT.define("bad_template", "{field} is not a Jinja template: {detail}")
|
|
@@ -502,6 +510,11 @@ ALERT_UNKNOWN_PIPELINE = ALERT.define("unknown_pipeline", "no pipeline coded {co
|
|
|
502
510
|
|
|
503
511
|
ALERT_UNKNOWN_CONNECTION = ALERT.define("unknown_connection", "no connection coded {code}")
|
|
504
512
|
|
|
513
|
+
TARGET_HAS_NO_NOTIFIER = ALERT.define(
|
|
514
|
+
"target_has_no_notifier",
|
|
515
|
+
"connection {code} is of kind {kind}, which no installed notifier delivers through ({installed})",
|
|
516
|
+
)
|
|
517
|
+
|
|
505
518
|
ALERT_VERSION_GONE = ALERT.define("version_gone", "run {run} pins a pipeline version that is gone")
|
|
506
519
|
|
|
507
520
|
NOTIFIER_NOT_ON_WORKER = ALERT.define("notifier_not_on_worker", "notifier {notifier} is not installed on this worker")
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Programmatic Alembic access, so the CLI, the server, and the tests drive one migration history."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Final
|
|
9
|
+
|
|
10
|
+
import sqlalchemy as sa
|
|
11
|
+
from alembic import command
|
|
12
|
+
from alembic.autogenerate import compare_metadata
|
|
13
|
+
from alembic.config import Config
|
|
14
|
+
from alembic.runtime.migration import MigrationContext
|
|
15
|
+
from alembic.script import ScriptDirectory
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
from sqlalchemy import Connection
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
19
|
+
|
|
20
|
+
from dirigent_core.config import Settings, get_settings
|
|
21
|
+
from dirigent_core.database import create_engine
|
|
22
|
+
from dirigent_core.models import Base
|
|
23
|
+
|
|
24
|
+
#: The migration scripts ship inside the package, so no working directory is assumed.
|
|
25
|
+
ALEMBIC_DIRECTORY = Path(__file__).resolve().parent / "alembic"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def alembic_config(settings: Settings | None = None) -> Config:
|
|
29
|
+
"""Build an Alembic config pointing at the packaged migrations and the configured database."""
|
|
30
|
+
resolved = settings or get_settings()
|
|
31
|
+
config = Config()
|
|
32
|
+
config.set_main_option("script_location", str(ALEMBIC_DIRECTORY))
|
|
33
|
+
config.set_main_option("sqlalchemy.url", resolved.database_url)
|
|
34
|
+
config.set_main_option("timezone", "UTC")
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_command(connection: Connection, name: str, revision: str, settings: Settings | None) -> None:
|
|
39
|
+
"""Run one Alembic command on an established synchronous connection."""
|
|
40
|
+
config = alembic_config(settings)
|
|
41
|
+
config.attributes["connection"] = connection
|
|
42
|
+
getattr(command, name)(config, revision)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _with_connection(name: str, revision: str, settings: Settings | None) -> None:
|
|
46
|
+
"""Open the async engine and hand a synchronous connection to Alembic."""
|
|
47
|
+
engine = create_engine(settings or get_settings())
|
|
48
|
+
try:
|
|
49
|
+
async with engine.begin() as connection:
|
|
50
|
+
await connection.run_sync(_run_command, name, revision, settings)
|
|
51
|
+
finally:
|
|
52
|
+
await engine.dispose()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def upgrade_async(revision: str = "head", settings: Settings | None = None) -> None:
|
|
56
|
+
"""Bring the database up to a revision, creating the schema on an empty database."""
|
|
57
|
+
await _with_connection("upgrade", revision, settings)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def downgrade_async(revision: str, settings: Settings | None = None) -> None:
|
|
61
|
+
"""Take the database back to a revision."""
|
|
62
|
+
await _with_connection("downgrade", revision, settings)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_revision(connection: Connection) -> str | None:
|
|
66
|
+
"""Read the alembic_version stamp from an established connection."""
|
|
67
|
+
return MigrationContext.configure(connection).get_current_revision()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def current_revision_async(settings: Settings | None = None) -> str | None:
|
|
71
|
+
"""Read the revision the database is stamped with, or None when it has never been migrated."""
|
|
72
|
+
engine = create_engine(settings or get_settings())
|
|
73
|
+
try:
|
|
74
|
+
async with engine.connect() as connection:
|
|
75
|
+
return await connection.run_sync(_read_revision)
|
|
76
|
+
finally:
|
|
77
|
+
await engine.dispose()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def upgrade(revision: str = "head", settings: Settings | None = None) -> None:
|
|
81
|
+
"""Run an upgrade from synchronous code, such as the CLI."""
|
|
82
|
+
asyncio.run(upgrade_async(revision, settings))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def downgrade(revision: str, settings: Settings | None = None) -> None:
|
|
86
|
+
"""Run a downgrade from synchronous code, such as the CLI."""
|
|
87
|
+
asyncio.run(downgrade_async(revision, settings))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def current_revision(settings: Settings | None = None) -> str | None:
|
|
91
|
+
"""Read the current revision from synchronous code, such as the CLI."""
|
|
92
|
+
return asyncio.run(current_revision_async(settings))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def head_revision(settings: Settings | None = None) -> str | None:
|
|
96
|
+
"""Read the newest revision the packaged migrations define."""
|
|
97
|
+
return ScriptDirectory.from_config(alembic_config(settings)).get_current_head()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def history(settings: Settings | None = None) -> str:
|
|
101
|
+
"""Render the migration history as Alembic prints it, without touching the database."""
|
|
102
|
+
config = alembic_config(settings)
|
|
103
|
+
buffer = io.StringIO()
|
|
104
|
+
config.stdout = buffer
|
|
105
|
+
command.history(config, verbose=False)
|
|
106
|
+
return buffer.getvalue()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DifferenceKind(StrEnum):
|
|
110
|
+
"""What one difference between a live database and the models is."""
|
|
111
|
+
|
|
112
|
+
MISSING_TABLE = "missing_table"
|
|
113
|
+
"""The models declare a table the database has not got."""
|
|
114
|
+
|
|
115
|
+
MISSING_COLUMN = "missing_column"
|
|
116
|
+
"""A declared table is there, without a column the models give it."""
|
|
117
|
+
|
|
118
|
+
EXTRA_COLUMN = "extra_column"
|
|
119
|
+
"""A declared table carries a column the models do not declare."""
|
|
120
|
+
|
|
121
|
+
CHANGED = "changed"
|
|
122
|
+
"""A declared table differs some other way: a column's type, an index, a constraint."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: How each kind reads after the table or column it is about.
|
|
126
|
+
_WORDING: Final[dict[DifferenceKind, str]] = {
|
|
127
|
+
DifferenceKind.MISSING_TABLE: "missing",
|
|
128
|
+
DifferenceKind.MISSING_COLUMN: "missing",
|
|
129
|
+
DifferenceKind.EXTRA_COLUMN: "unexpected",
|
|
130
|
+
DifferenceKind.CHANGED: "changed",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class SchemaDifference(BaseModel):
|
|
135
|
+
"""One way a live database differs from the models, named where it is."""
|
|
136
|
+
|
|
137
|
+
model_config = ConfigDict(frozen=True)
|
|
138
|
+
|
|
139
|
+
kind: DifferenceKind
|
|
140
|
+
"""What the difference is."""
|
|
141
|
+
|
|
142
|
+
table: str
|
|
143
|
+
"""The table it is in."""
|
|
144
|
+
|
|
145
|
+
column: str | None = None
|
|
146
|
+
"""The column it is in, when the difference is about one."""
|
|
147
|
+
|
|
148
|
+
def __str__(self) -> str:
|
|
149
|
+
"""Render the difference as the phrase a refusal names it by."""
|
|
150
|
+
where = f"{self.table}.{self.column}" if self.column else f"table {self.table}"
|
|
151
|
+
return f"{where} {_WORDING[self.kind]}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _flattened(differences: Iterable[Any]) -> Iterator[tuple[Any, ...]]:
|
|
155
|
+
"""Walk Alembic's diff list, which groups several changes to one column in a nested list."""
|
|
156
|
+
for entry in differences:
|
|
157
|
+
if isinstance(entry, list):
|
|
158
|
+
yield from entry
|
|
159
|
+
else:
|
|
160
|
+
yield entry
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _named(difference: tuple[Any, ...]) -> SchemaDifference | None:
|
|
164
|
+
"""Name the table and column one Alembic difference is about.
|
|
165
|
+
|
|
166
|
+
A table the models do not declare is not a difference: the database may be shared, and
|
|
167
|
+
nothing dirigent runs reads it.
|
|
168
|
+
"""
|
|
169
|
+
what = difference[0]
|
|
170
|
+
if what == "add_table":
|
|
171
|
+
return SchemaDifference(kind=DifferenceKind.MISSING_TABLE, table=difference[1].name)
|
|
172
|
+
if what == "remove_table":
|
|
173
|
+
return None
|
|
174
|
+
if what == "add_column":
|
|
175
|
+
return SchemaDifference(kind=DifferenceKind.MISSING_COLUMN, table=difference[2], column=difference[3].name)
|
|
176
|
+
if what == "remove_column":
|
|
177
|
+
return SchemaDifference(kind=DifferenceKind.EXTRA_COLUMN, table=difference[2], column=difference[3].name)
|
|
178
|
+
if what.startswith("modify_"):
|
|
179
|
+
return SchemaDifference(kind=DifferenceKind.CHANGED, table=difference[2], column=difference[3])
|
|
180
|
+
element = difference[1]
|
|
181
|
+
table = element if isinstance(element, sa.Table) else getattr(element, "table", None)
|
|
182
|
+
return None if table is None else SchemaDifference(kind=DifferenceKind.CHANGED, table=table.name)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _compare(connection: Connection) -> list[SchemaDifference]:
|
|
186
|
+
"""Diff one live connection's schema against the ORM metadata."""
|
|
187
|
+
context = MigrationContext.configure(connection, opts={"compare_type": True, "include_schemas": False})
|
|
188
|
+
found = [_named(difference) for difference in _flattened(compare_metadata(context, Base.metadata))]
|
|
189
|
+
differences = [one for one in found if one is not None]
|
|
190
|
+
absent = {one.table for one in differences if one.kind is DifferenceKind.MISSING_TABLE}
|
|
191
|
+
# Every index and constraint of a table that is not there restates the missing table.
|
|
192
|
+
return [one for one in differences if one.kind is DifferenceKind.MISSING_TABLE or one.table not in absent]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def schema_differences(engine: AsyncEngine) -> list[SchemaDifference]:
|
|
196
|
+
"""Say how the live database differs from the models, in one reflection.
|
|
197
|
+
|
|
198
|
+
Empty means the database holds the schema this code was built against. Before 1.0 the
|
|
199
|
+
baseline migration is edited in place, so a database an older dirigent wrote is stamped
|
|
200
|
+
at the same revision and the stamp cannot answer this; reflection can.
|
|
201
|
+
"""
|
|
202
|
+
async with engine.connect() as connection:
|
|
203
|
+
return await connection.run_sync(_compare)
|
|
@@ -13,6 +13,7 @@ from dirigent_client.enums import (
|
|
|
13
13
|
AttemptKind,
|
|
14
14
|
AttemptStatus,
|
|
15
15
|
FiringOutcome,
|
|
16
|
+
Importance,
|
|
16
17
|
LogLevel,
|
|
17
18
|
NotificationStatus,
|
|
18
19
|
ProvenanceSource,
|
|
@@ -91,6 +92,17 @@ class Pipeline(Entity):
|
|
|
91
92
|
tags: Mapped[JsonList] = mapped_column(JsonDocument, nullable=False, default=list)
|
|
92
93
|
"""What the current document says this pipeline is for; an apply replaces the whole list."""
|
|
93
94
|
|
|
95
|
+
importance: Mapped[Importance] = mapped_column(
|
|
96
|
+
string_enum(Importance, "importance"),
|
|
97
|
+
nullable=False,
|
|
98
|
+
default=Importance.NORMAL,
|
|
99
|
+
server_default=Importance.NORMAL.value,
|
|
100
|
+
)
|
|
101
|
+
"""How much the current document says this pipeline matters.
|
|
102
|
+
|
|
103
|
+
On the row rather than read from the version document, so an alert rule matches without
|
|
104
|
+
loading one."""
|
|
105
|
+
|
|
94
106
|
active: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True, server_default=sa.true())
|
|
95
107
|
current_version: Mapped[int | None] = mapped_column(sa.Integer)
|
|
96
108
|
|
|
@@ -485,6 +497,9 @@ class AlertRule(Entity):
|
|
|
485
497
|
string_enum(AlertScope, "alert_scope"), nullable=False, default=AlertScope.GLOBAL
|
|
486
498
|
)
|
|
487
499
|
pipeline_id: Mapped[UUID | None] = mapped_column(sa.ForeignKey("pipelines.id", ondelete="CASCADE"), index=True)
|
|
500
|
+
importance: Mapped[Importance | None] = mapped_column(string_enum(Importance, "importance"))
|
|
501
|
+
"""The least importance a pipeline must carry before this rule fires; null fires for every one."""
|
|
502
|
+
|
|
488
503
|
notifier: Mapped[str] = mapped_column(sa.String(100), nullable=False)
|
|
489
504
|
connection_id: Mapped[UUID | None] = mapped_column(sa.ForeignKey("connections.id", ondelete="RESTRICT"))
|
|
490
505
|
template: Mapped[str | None] = mapped_column(sa.Text)
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
"""Programmatic Alembic access, so the CLI, the server, and the tests drive one migration history."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import io
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
from alembic import command
|
|
8
|
-
from alembic.config import Config
|
|
9
|
-
from alembic.runtime.migration import MigrationContext
|
|
10
|
-
from alembic.script import ScriptDirectory
|
|
11
|
-
from sqlalchemy import Connection
|
|
12
|
-
|
|
13
|
-
from dirigent_core.config import Settings, get_settings
|
|
14
|
-
from dirigent_core.database import create_engine
|
|
15
|
-
|
|
16
|
-
#: The migration scripts ship inside the package, so no working directory is assumed.
|
|
17
|
-
ALEMBIC_DIRECTORY = Path(__file__).resolve().parent / "alembic"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def alembic_config(settings: Settings | None = None) -> Config:
|
|
21
|
-
"""Build an Alembic config pointing at the packaged migrations and the configured database."""
|
|
22
|
-
resolved = settings or get_settings()
|
|
23
|
-
config = Config()
|
|
24
|
-
config.set_main_option("script_location", str(ALEMBIC_DIRECTORY))
|
|
25
|
-
config.set_main_option("sqlalchemy.url", resolved.database_url)
|
|
26
|
-
config.set_main_option("timezone", "UTC")
|
|
27
|
-
return config
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _run_command(connection: Connection, name: str, revision: str, settings: Settings | None) -> None:
|
|
31
|
-
"""Run one Alembic command on an established synchronous connection."""
|
|
32
|
-
config = alembic_config(settings)
|
|
33
|
-
config.attributes["connection"] = connection
|
|
34
|
-
getattr(command, name)(config, revision)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
async def _with_connection(name: str, revision: str, settings: Settings | None) -> None:
|
|
38
|
-
"""Open the async engine and hand a synchronous connection to Alembic."""
|
|
39
|
-
engine = create_engine(settings or get_settings())
|
|
40
|
-
try:
|
|
41
|
-
async with engine.begin() as connection:
|
|
42
|
-
await connection.run_sync(_run_command, name, revision, settings)
|
|
43
|
-
finally:
|
|
44
|
-
await engine.dispose()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
async def upgrade_async(revision: str = "head", settings: Settings | None = None) -> None:
|
|
48
|
-
"""Bring the database up to a revision, creating the schema on an empty database."""
|
|
49
|
-
await _with_connection("upgrade", revision, settings)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
async def downgrade_async(revision: str, settings: Settings | None = None) -> None:
|
|
53
|
-
"""Take the database back to a revision."""
|
|
54
|
-
await _with_connection("downgrade", revision, settings)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _read_revision(connection: Connection) -> str | None:
|
|
58
|
-
"""Read the alembic_version stamp from an established connection."""
|
|
59
|
-
return MigrationContext.configure(connection).get_current_revision()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
async def current_revision_async(settings: Settings | None = None) -> str | None:
|
|
63
|
-
"""Read the revision the database is stamped with, or None when it has never been migrated."""
|
|
64
|
-
engine = create_engine(settings or get_settings())
|
|
65
|
-
try:
|
|
66
|
-
async with engine.connect() as connection:
|
|
67
|
-
return await connection.run_sync(_read_revision)
|
|
68
|
-
finally:
|
|
69
|
-
await engine.dispose()
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def upgrade(revision: str = "head", settings: Settings | None = None) -> None:
|
|
73
|
-
"""Run an upgrade from synchronous code, such as the CLI."""
|
|
74
|
-
asyncio.run(upgrade_async(revision, settings))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def downgrade(revision: str, settings: Settings | None = None) -> None:
|
|
78
|
-
"""Run a downgrade from synchronous code, such as the CLI."""
|
|
79
|
-
asyncio.run(downgrade_async(revision, settings))
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def current_revision(settings: Settings | None = None) -> str | None:
|
|
83
|
-
"""Read the current revision from synchronous code, such as the CLI."""
|
|
84
|
-
return asyncio.run(current_revision_async(settings))
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def head_revision(settings: Settings | None = None) -> str | None:
|
|
88
|
-
"""Read the newest revision the packaged migrations define."""
|
|
89
|
-
return ScriptDirectory.from_config(alembic_config(settings)).get_current_head()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def history(settings: Settings | None = None) -> str:
|
|
93
|
-
"""Render the migration history as Alembic prints it, without touching the database."""
|
|
94
|
-
config = alembic_config(settings)
|
|
95
|
-
buffer = io.StringIO()
|
|
96
|
-
config.stdout = buffer
|
|
97
|
-
command.history(config, verbose=False)
|
|
98
|
-
return buffer.getvalue()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|