dirigent-core 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. dirigent_core/__init__.py +26 -0
  2. dirigent_core/alembic/env.py +71 -0
  3. dirigent_core/alembic/script.py.mako +26 -0
  4. dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
  5. dirigent_core/alerting.py +805 -0
  6. dirigent_core/artifacts.py +73 -0
  7. dirigent_core/auth.py +529 -0
  8. dirigent_core/blockdocs.py +223 -0
  9. dirigent_core/config.py +421 -0
  10. dirigent_core/configdocs.py +134 -0
  11. dirigent_core/database.py +177 -0
  12. dirigent_core/directory.py +339 -0
  13. dirigent_core/documents.py +878 -0
  14. dirigent_core/documentschema.py +92 -0
  15. dirigent_core/engine/__init__.py +120 -0
  16. dirigent_core/engine/claim.py +156 -0
  17. dirigent_core/engine/context.py +420 -0
  18. dirigent_core/engine/definition.py +543 -0
  19. dirigent_core/engine/executor.py +1029 -0
  20. dirigent_core/engine/failure.py +71 -0
  21. dirigent_core/engine/recovery.py +137 -0
  22. dirigent_core/engine/references.py +239 -0
  23. dirigent_core/engine/runs.py +865 -0
  24. dirigent_core/engine/services.py +74 -0
  25. dirigent_core/engine/state.py +434 -0
  26. dirigent_core/ids.py +39 -0
  27. dirigent_core/logging.py +310 -0
  28. dirigent_core/migrations.py +98 -0
  29. dirigent_core/models.py +626 -0
  30. dirigent_core/pipelines.py +494 -0
  31. dirigent_core/plugins.py +255 -0
  32. dirigent_core/protocol.py +276 -0
  33. dirigent_core/py.typed +0 -0
  34. dirigent_core/ratelimit.py +42 -0
  35. dirigent_core/registry.py +32 -0
  36. dirigent_core/retention.py +293 -0
  37. dirigent_core/scheduler.py +491 -0
  38. dirigent_core/schemas.py +147 -0
  39. dirigent_core/secrets.py +164 -0
  40. dirigent_core/storage.py +349 -0
  41. dirigent_core/telemetry.py +402 -0
  42. dirigent_core/trigger_documents.py +193 -0
  43. dirigent_core/triggers/__init__.py +117 -0
  44. dirigent_core/triggers/backfill.py +131 -0
  45. dirigent_core/triggers/materialize.py +218 -0
  46. dirigent_core/triggers/schedules.py +531 -0
  47. dirigent_core/triggers/webhooks.py +586 -0
  48. dirigent_core/types.py +59 -0
  49. dirigent_core/worker.py +350 -0
  50. dirigent_core-0.9.0.dist-info/METADATA +29 -0
  51. dirigent_core-0.9.0.dist-info/RECORD +53 -0
  52. dirigent_core-0.9.0.dist-info/WHEEL +4 -0
  53. dirigent_core-0.9.0.dist-info/licenses/LICENSE +18 -0
@@ -0,0 +1,420 @@
1
+ """The engine's side of the bargain: the StepContext a block is handed.
2
+
3
+ Log entries are buffered rather than written as they are emitted. The executor flushes the
4
+ buffer while the attempt runs, and the outcome transaction writes whatever is left, so a
5
+ long step is visible working and its last lines still land with the outcome.
6
+ """
7
+
8
+ import asyncio
9
+ from collections.abc import Mapping
10
+ from datetime import UTC, datetime, timedelta
11
+ from fnmatch import fnmatch
12
+ from pathlib import Path
13
+ from typing import Any, Final
14
+ from uuid import UUID
15
+
16
+ import httpx2
17
+ import sqlalchemy as sa
18
+ from jsonschema import FormatChecker
19
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue, SecretStr
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from dirigent_client.enums import LogLevel
23
+ from dirigent_common import JsonMap
24
+ from dirigent_core.logging import debug_kept, get_logger
25
+ from dirigent_core.models import Connection, Schema
26
+ from dirigent_core.secrets import SecretBox
27
+ from dirigent_core.storage import AttemptStorage, Storage, work_dir
28
+ from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Logger, Runs, StorageBackend
29
+
30
+ #: What an HTTP connection is assumed to call its fields, so core can build a client for
31
+ #: any connection kind without importing the package that contributed it.
32
+ BASE_URL_FIELD = "base_url"
33
+ VERIFY_TLS_FIELD = "verify_tls"
34
+ TIMEOUT_FIELD = "timeout"
35
+ BEARER_FIELD = "bearer_token"
36
+ API_TOKEN_FIELD = "api_token"
37
+ API_TOKEN_SCHEME_FIELD = "api_token_scheme"
38
+ BASIC_USER_FIELD = "basic_username"
39
+ BASIC_PASSWORD_FIELD = "basic_password"
40
+
41
+ #: The authorization scheme an ``api_token`` is sent under when the config names none, which
42
+ #: is the scheme a personal access token is usually presented with.
43
+ DEFAULT_API_TOKEN_SCHEME = "ApiToken"
44
+
45
+ DEFAULT_HTTP_TIMEOUT = 30.0
46
+
47
+ _logger = get_logger("block")
48
+
49
+
50
+ class UnknownConnection(BlockFailure):
51
+ """A block asked for a connection this instance does not have."""
52
+
53
+ def __init__(self, ref: str, known: list[str]) -> None:
54
+ """Name the connection and the ones that exist, and refuse to retry."""
55
+ available = ", ".join(sorted(known)) or "none are configured"
56
+ super().__init__(f"no connection coded {ref!r} ({available})", error_class=ErrorClass.REJECTED)
57
+
58
+
59
+ class UnknownSchema(BlockFailure):
60
+ """A block asked for a named schema this instance does not hold."""
61
+
62
+ def __init__(self, code: str, known: list[str]) -> None:
63
+ """Name the schema and the ones that exist, and refuse to retry."""
64
+ available = ", ".join(sorted(known)) or "none are held"
65
+ super().__init__(f"no schema coded {code!r} ({available})", error_class=ErrorClass.REJECTED)
66
+
67
+
68
+ class ConnectionRecord(BaseModel):
69
+ """One stored connection, still sealed, as the claim transaction snapshotted it."""
70
+
71
+ model_config = ConfigDict(frozen=True)
72
+
73
+ code: str
74
+ kind: str
75
+ config: JsonMap
76
+ envelope: bytes | None = None
77
+ key_id: str | None = None
78
+
79
+
80
+ async def load_connections(session: AsyncSession) -> dict[str, ConnectionRecord]:
81
+ """Snapshot every connection for one attempt.
82
+
83
+ The whole table is read because ``StepContext.connection`` is synchronous by contract and
84
+ a block may ask for any code at any point in its call.
85
+ """
86
+ rows = await session.execute(sa.select(Connection))
87
+ return {
88
+ row.code: ConnectionRecord(
89
+ code=row.code,
90
+ kind=row.kind,
91
+ config=dict(row.config),
92
+ envelope=row.secret_envelope,
93
+ key_id=row.secret_key_id,
94
+ )
95
+ for row in rows.scalars()
96
+ }
97
+
98
+
99
+ async def load_schemas(session: AsyncSession) -> dict[str, JsonMap]:
100
+ """Snapshot every named schema for one attempt, code to its JSON Schema body.
101
+
102
+ The whole table is read for the same reason connections are: ``StepContext.schema`` is
103
+ synchronous by contract, so a block may name any code at any point in its call.
104
+ """
105
+ rows = await session.execute(sa.select(Schema))
106
+ return {row.code: dict(row.body) for row in rows.scalars()}
107
+
108
+
109
+ MAX_MESSAGE_CHARS: Final = 4000
110
+
111
+
112
+ def buffer_full(limit: int) -> str:
113
+ """The one warning a step that logged past its quota gets in place of the rest."""
114
+ return f"this step logged more than {limit} entries; the rest were dropped"
115
+
116
+
117
+ #: One log entry as an insert's parameters: what the buffer holds and what a flush sends.
118
+ type LogRow = dict[str, Any]
119
+
120
+
121
+ def log_row(
122
+ *,
123
+ run_id: UUID,
124
+ run_item_id: UUID | None,
125
+ step_attempt_id: UUID | None,
126
+ step_name: str,
127
+ level: LogLevel,
128
+ message: str,
129
+ fields: JsonMap | None,
130
+ created_at: datetime,
131
+ ) -> LogRow:
132
+ """Spell one entry as an insert's parameters, which a failed flush can send again."""
133
+ return {
134
+ "run_id": run_id,
135
+ "run_item_id": run_item_id,
136
+ "step_attempt_id": step_attempt_id,
137
+ "step_name": step_name,
138
+ "level": level,
139
+ "message": message,
140
+ "fields": fields,
141
+ "created_at": created_at,
142
+ }
143
+
144
+
145
+ #: Severity order, for the keep gate: an entry below the kept level is dropped at record time.
146
+ LEVEL_RANK: Final = {LogLevel.DEBUG: 0, LogLevel.INFO: 1, LogLevel.WARNING: 2, LogLevel.ERROR: 3}
147
+
148
+
149
+ def kept_level(log_levels: Mapping[str, object] | None, block_id: str) -> LogLevel:
150
+ """Resolve which level a run keeps for one block, from its pattern map.
151
+
152
+ Patterns are fnmatch over the block id, and the most specific match wins -- the longest
153
+ pattern, with ``*`` last -- so ``{"*": "info", "acme.*": "debug"}`` is one loud family
154
+ in a quiet run. No map, and no matching pattern, keep info and up.
155
+ """
156
+ if not log_levels:
157
+ return LogLevel.INFO
158
+ chosen: tuple[int, LogLevel] | None = None
159
+ for pattern, value in log_levels.items():
160
+ if not fnmatch(block_id, pattern):
161
+ continue
162
+ specificity = len(pattern) if pattern != "*" else -1
163
+ if chosen is None or specificity > chosen[0]:
164
+ chosen = (specificity, LogLevel(str(value)))
165
+ return chosen[1] if chosen is not None else LogLevel.INFO
166
+
167
+
168
+ class BufferedLogger(BaseModel):
169
+ """The scoped log a block writes to: buffered in memory, flushed while the attempt runs."""
170
+
171
+ model_config = ConfigDict(arbitrary_types_allowed=True)
172
+
173
+ run_id: UUID
174
+ step_name: str
175
+ step_attempt_id: UUID | None = None
176
+ run_item_id: UUID | None = None
177
+ limit: int
178
+ """How many entries this attempt may log before the rest are dropped with one warning."""
179
+ batch: int
180
+ """How many buffered entries set ``full``, which is what wakes the flusher early."""
181
+ keep: LogLevel = LogLevel.INFO
182
+ """The least severe level this run keeps for the block behind this buffer."""
183
+ entries: list[LogRow] = Field(default_factory=list[LogRow])
184
+ recorded: int = 0
185
+ """How many entries this attempt has logged, including the ones already flushed."""
186
+ full: asyncio.Event = Field(default_factory=asyncio.Event)
187
+ """Set when the buffer reaches the size gate, which is what wakes the flusher."""
188
+
189
+ def debug(self, message: str, **fields: JsonValue) -> None:
190
+ """Record a debug-level entry."""
191
+ self._record(LogLevel.DEBUG, message, fields)
192
+
193
+ def info(self, message: str, **fields: JsonValue) -> None:
194
+ """Record an info-level entry."""
195
+ self._record(LogLevel.INFO, message, fields)
196
+
197
+ def warning(self, message: str, **fields: JsonValue) -> None:
198
+ """Record a warning-level entry."""
199
+ self._record(LogLevel.WARNING, message, fields)
200
+
201
+ def error(self, message: str, **fields: JsonValue) -> None:
202
+ """Record an error-level entry."""
203
+ self._record(LogLevel.ERROR, message, fields)
204
+
205
+ def _record(self, level: LogLevel, message: str, fields: JsonMap) -> None:
206
+ """Buffer one product-telemetry entry and mirror it to the process log."""
207
+ if LEVEL_RANK[level] < LEVEL_RANK[self.keep]:
208
+ return
209
+ if self.recorded > self.limit:
210
+ return
211
+ if self.recorded == self.limit:
212
+ level, message, fields = LogLevel.WARNING, buffer_full(self.limit), {}
213
+ elif len(message) > MAX_MESSAGE_CHARS:
214
+ message = f"{message[:MAX_MESSAGE_CHARS]} [truncated, {len(message)} characters]"
215
+ self.entries.append(
216
+ log_row(
217
+ run_id=self.run_id,
218
+ run_item_id=self.run_item_id,
219
+ step_attempt_id=self.step_attempt_id,
220
+ step_name=self.step_name,
221
+ level=level,
222
+ message=message,
223
+ fields=dict(fields) or None,
224
+ created_at=datetime.now(UTC),
225
+ )
226
+ )
227
+ self.recorded += 1
228
+ if len(self.entries) >= self.batch:
229
+ self.full.set()
230
+ # The mirror renders every line it is given, so the level is checked before the call
231
+ # rather than inside it. The block's own fields are nested rather than spread: a block
232
+ # is free to name one of them `level` or `event`, and spreading those raises a
233
+ # TypeError out of the block's log call.
234
+ if debug_kept():
235
+ _logger.debug(message, log_level=level.value, fields=dict(fields))
236
+
237
+ def drain(self) -> list[LogRow]:
238
+ """Take the buffered entries, leaving the logger empty for the next call."""
239
+ taken, self.entries = self.entries, []
240
+ self.full.clear()
241
+ return taken
242
+
243
+ def restore(self, entries: list[LogRow]) -> None:
244
+ """Put drained entries back at the front, for the next flush or the outcome."""
245
+ self.entries = entries + self.entries
246
+ if len(self.entries) >= self.batch:
247
+ self.full.set()
248
+
249
+
250
+ class EngineStepContext:
251
+ """The concrete StepContext the engine hands a block for exactly one call."""
252
+
253
+ def __init__(
254
+ self,
255
+ *,
256
+ run_id: UUID,
257
+ step: str,
258
+ run_item_id: UUID | None,
259
+ attempt: int,
260
+ started_at: datetime,
261
+ inline_capture: int,
262
+ params: JsonMap,
263
+ log: BufferedLogger,
264
+ storage: Storage,
265
+ scratch: str,
266
+ work_root: str,
267
+ connections: dict[str, ConnectionRecord],
268
+ connection_models: dict[str, type[BaseModel]],
269
+ secrets: SecretBox,
270
+ runs: Runs,
271
+ format_checker: FormatChecker,
272
+ storage_connections: Mapping[str, str] | None = None,
273
+ schemas: dict[str, JsonMap] | None = None,
274
+ cursor: JsonMap | None = None,
275
+ ) -> None:
276
+ """Bind the context to one attempt's scope."""
277
+ self.run_id: UUID = run_id
278
+ self.step: str = step
279
+ self.run_item_id: UUID | None = run_item_id
280
+ self.attempt: int = attempt
281
+ self.started_at: datetime = started_at
282
+ self.inline_capture: int = inline_capture
283
+ self.cursor: JsonMap | None = cursor
284
+ self.params: Mapping[str, JsonValue] = params
285
+ self.log: Logger = log
286
+ self._buffer = log
287
+ self._storage = storage
288
+ self._scratch = scratch
289
+ self._work_root = work_root
290
+ self._connections = connections
291
+ self._connection_models = connection_models
292
+ self._schemas = schemas or {}
293
+ self._secrets = secrets
294
+ self._runs = runs
295
+ self._format_checker = format_checker
296
+ self._storage_connections = dict(storage_connections or {})
297
+ self._bound_storage: AttemptStorage | None = None
298
+
299
+ @property
300
+ def storage(self) -> AttemptStorage:
301
+ """Access URI-addressed storage across every registered scheme.
302
+
303
+ A scheme configured from a coded connection is bound on first use, on the worker path,
304
+ where connection secrets are opened and nowhere else.
305
+ """
306
+ if self._bound_storage is None:
307
+ self._bound_storage = self._storage.bound_by(self._configure_backend)
308
+ return self._bound_storage
309
+
310
+ def _configure_backend(self, scheme: str, backend: StorageBackend) -> StorageBackend:
311
+ """Bind one scheme to the connection this instance configures it from, if it names one."""
312
+ ref = self._storage_connections.get(scheme)
313
+ if ref is None:
314
+ return backend
315
+ return backend.configured(self.connection(ref, backend.config_model))
316
+
317
+ @property
318
+ def scratch(self) -> str:
319
+ """Return the run-scoped URI prefix for intermediate artifacts."""
320
+ return self._scratch
321
+
322
+ @property
323
+ def work(self) -> Path:
324
+ """Return the run's directory on this worker's own filesystem, made on first read.
325
+
326
+ Absolute whatever the setting said: a child process is given both a working directory
327
+ and the paths it is to touch, and a relative one would be resolved twice.
328
+ """
329
+ directory = work_dir(self._work_root, self.run_id)
330
+ directory.mkdir(parents=True, exist_ok=True)
331
+ return directory
332
+
333
+ @property
334
+ def runs(self) -> Runs:
335
+ """Start, observe, and cancel runs on this instance, attributed to the calling run."""
336
+ return self._runs
337
+
338
+ def connection[C: BaseModel](self, ref: ConnectionRef, model: type[C]) -> C:
339
+ """Resolve a connection by code, opened and validated against the block's own model."""
340
+ record = self._connections.get(ref)
341
+ if record is None:
342
+ raise UnknownConnection(ref, list(self._connections))
343
+ return self._secrets.decrypt_config(model, record.config, record.envelope, key_id=record.key_id)
344
+
345
+ def storage_connection[C: BaseModel](self, scheme: str, model: type[C]) -> C | None:
346
+ """Resolve the connection this instance configures a storage scheme from, or None."""
347
+ ref = self._storage_connections.get(scheme)
348
+ return None if ref is None else self.connection(ref, model)
349
+
350
+ def schema(self, code: str) -> JsonMap:
351
+ """Resolve a named JSON Schema by code, as the claim transaction snapshotted it."""
352
+ body = self._schemas.get(code)
353
+ if body is None:
354
+ raise UnknownSchema(code, list(self._schemas))
355
+ return body
356
+
357
+ def format_checker(self) -> FormatChecker:
358
+ """The instance's assembled checker: base formats plus every contributed one."""
359
+ return self._format_checker
360
+
361
+ def http(self, ref: ConnectionRef) -> httpx2.AsyncClient:
362
+ """Build an HTTP client for a connection read by code, already carrying its settings."""
363
+ record = self._connections.get(ref)
364
+ if record is None:
365
+ raise UnknownConnection(ref, list(self._connections))
366
+ model = self._connection_models.get(record.kind)
367
+ if model is None:
368
+ raise BlockFailure(
369
+ f"connection {ref!r} has kind {record.kind!r}, which no installed plugin contributes",
370
+ error_class=ErrorClass.REJECTED,
371
+ )
372
+ return build_http_client(self.connection(ref, model))
373
+
374
+
375
+ def build_http_client(config: BaseModel) -> httpx2.AsyncClient:
376
+ """Build a client from any connection config that names the standard HTTP fields.
377
+
378
+ The fields are read structurally, so a config that names none of them gets a bare client.
379
+
380
+ A credential is read from one of two fields. ``bearer_token`` is sent as
381
+ ``Authorization: Bearer``; ``api_token`` is sent under the scheme the config's
382
+ ``api_token_scheme`` names, and ``ApiToken`` when it names none, which is the scheme a
383
+ personal access token usually needs. ``bearer_token`` wins if a config carries both.
384
+ ``basic_username`` and ``basic_password`` become the client's auth, which httpx applies
385
+ per request, so basic credentials beside a token win over the header.
386
+ """
387
+ base_url = _text(config, BASE_URL_FIELD) or ""
388
+ configured = getattr(config, TIMEOUT_FIELD, None)
389
+ timeout = configured.total_seconds() if isinstance(configured, timedelta) else DEFAULT_HTTP_TIMEOUT
390
+ verify = getattr(config, VERIFY_TLS_FIELD, True)
391
+ headers: dict[str, str] = {}
392
+ bearer = _text(config, BEARER_FIELD)
393
+ api_token = _text(config, API_TOKEN_FIELD)
394
+ if bearer:
395
+ headers["Authorization"] = f"Bearer {bearer}"
396
+ elif api_token:
397
+ scheme = _text(config, API_TOKEN_SCHEME_FIELD) or DEFAULT_API_TOKEN_SCHEME
398
+ headers["Authorization"] = f"{scheme} {api_token}"
399
+ auth: httpx2.Auth | None = None
400
+ user = _text(config, BASIC_USER_FIELD)
401
+ password = _text(config, BASIC_PASSWORD_FIELD)
402
+ if user:
403
+ auth = httpx2.BasicAuth(user, password or "")
404
+ return httpx2.AsyncClient(
405
+ base_url=base_url,
406
+ headers=headers,
407
+ auth=auth,
408
+ verify=bool(verify),
409
+ timeout=timeout,
410
+ )
411
+
412
+
413
+ def _text(config: BaseModel, name: str) -> str | None:
414
+ """Read a field that may be a plain string, a secret, or absent."""
415
+ value: Any = getattr(config, name, None)
416
+ if value is None:
417
+ return None
418
+ if isinstance(value, SecretStr):
419
+ return value.get_secret_value()
420
+ return str(value)