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.
- dirigent_core/__init__.py +26 -0
- dirigent_core/alembic/env.py +71 -0
- dirigent_core/alembic/script.py.mako +26 -0
- dirigent_core/alembic/versions/0001_baseline_schema.py +1016 -0
- dirigent_core/alerting.py +805 -0
- dirigent_core/artifacts.py +73 -0
- dirigent_core/auth.py +529 -0
- dirigent_core/blockdocs.py +223 -0
- dirigent_core/config.py +421 -0
- dirigent_core/configdocs.py +134 -0
- dirigent_core/database.py +177 -0
- dirigent_core/directory.py +339 -0
- dirigent_core/documents.py +878 -0
- dirigent_core/documentschema.py +92 -0
- dirigent_core/engine/__init__.py +120 -0
- dirigent_core/engine/claim.py +156 -0
- dirigent_core/engine/context.py +420 -0
- dirigent_core/engine/definition.py +543 -0
- dirigent_core/engine/executor.py +1029 -0
- dirigent_core/engine/failure.py +71 -0
- dirigent_core/engine/recovery.py +137 -0
- dirigent_core/engine/references.py +239 -0
- dirigent_core/engine/runs.py +865 -0
- dirigent_core/engine/services.py +74 -0
- dirigent_core/engine/state.py +434 -0
- dirigent_core/ids.py +39 -0
- dirigent_core/logging.py +310 -0
- dirigent_core/migrations.py +98 -0
- dirigent_core/models.py +626 -0
- dirigent_core/pipelines.py +494 -0
- dirigent_core/plugins.py +255 -0
- dirigent_core/protocol.py +276 -0
- dirigent_core/py.typed +0 -0
- dirigent_core/ratelimit.py +42 -0
- dirigent_core/registry.py +32 -0
- dirigent_core/retention.py +293 -0
- dirigent_core/scheduler.py +491 -0
- dirigent_core/schemas.py +147 -0
- dirigent_core/secrets.py +164 -0
- dirigent_core/storage.py +349 -0
- dirigent_core/telemetry.py +402 -0
- dirigent_core/trigger_documents.py +193 -0
- dirigent_core/triggers/__init__.py +117 -0
- dirigent_core/triggers/backfill.py +131 -0
- dirigent_core/triggers/materialize.py +218 -0
- dirigent_core/triggers/schedules.py +531 -0
- dirigent_core/triggers/webhooks.py +586 -0
- dirigent_core/types.py +59 -0
- dirigent_core/worker.py +350 -0
- dirigent_core-0.9.0.dist-info/METADATA +29 -0
- dirigent_core-0.9.0.dist-info/RECORD +53 -0
- dirigent_core-0.9.0.dist-info/WHEEL +4 -0
- 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)
|