021-python-clients 0.3.0__py3-none-any.whl → 0.4.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.
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 021-python-clients
3
- Version: 0.3.0
4
- Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and KMS, built on 021-python-observability
3
+ Version: 0.4.0
4
+ Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email, KMS, Secrets Manager, Bedrock and Kubernetes Jobs, built on 021-python-observability
5
5
  Project-URL: Repository, https://github.com/from021-io/python-clients
6
6
  Project-URL: Changelog, https://github.com/from021-io/python-clients/blob/main/CHANGELOG.md
7
7
  Project-URL: Contract, https://github.com/from021-io/python-clients/blob/main/CONTRACT.md
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
- Keywords: email,httpx,kms,observability,opentelemetry,qstash,redis,resend,s3,ses,sqs
10
+ Keywords: bedrock,email,httpx,kms,kubernetes,observability,opentelemetry,qstash,redis,resend,s3,secrets-manager,ses,sqs
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: Operating System :: OS Independent
@@ -58,6 +58,9 @@ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras yo
58
58
  | `clients_021.email` | none | `EmailMessage`, the `EmailSender` protocol, `ResendSender` (Resend's HTTP API) |
59
59
  | `clients_021.email.ses` | `aws` | `SesSender`: the same protocol over Amazon SES v2 |
60
60
  | `clients_021.crypto.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
61
+ | `clients_021.secrets.secrets_manager` | `aws` | `SecretsManagerClient`: read a secret by id, version id or stage; the value is never printed |
62
+ | `clients_021.compute.kubernetes` | none | `KubernetesJobsClient`: create (idempotent by name), read and delete Jobs in a namespace, in-cluster service account |
63
+ | `clients_021.llm.bedrock` | `aws` | `BedrockRuntime`: an observed `bedrock-runtime` client to pass to SDKs such as LangChain's `ChatBedrockConverse`; streams measured to the end, token usage counted |
61
64
 
62
65
  Every client takes the service's `Observability` (from `setup_observability`) and a
63
66
  `dependency` name, which becomes the `dependency` label of its metrics.
@@ -66,7 +69,7 @@ Every client takes the service's `Observability` (from `setup_observability`) an
66
69
 
67
70
  Providers with the same purpose live under one capability package: `email/` contains
68
71
  Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
69
- contains Redis; `crypto/` contains KMS. `http/` contains the httpx client and httpx2
72
+ contains Redis; `crypto/` contains KMS; `secrets/` contains Secrets Manager; `llm/` contains Bedrock; `compute/` contains Kubernetes Jobs. `http/` contains the httpx client and httpx2
70
73
  transport. Import providers explicitly:
71
74
 
72
75
  ```python
@@ -77,6 +80,9 @@ from clients_021.queue.qstash import QStashClient
77
80
  from clients_021.storage.s3 import S3Client
78
81
  from clients_021.cache.redis import RedisClient
79
82
  from clients_021.crypto.kms import KmsClient
83
+ from clients_021.secrets.secrets_manager import SecretsManagerClient
84
+ from clients_021.llm.bedrock import BedrockRuntime
85
+ from clients_021.compute.kubernetes import KubernetesJobsClient
80
86
  ```
81
87
 
82
88
  SQS supports polling, receipts and visibility changes; QStash delivers to an HTTP endpoint.
@@ -95,12 +101,16 @@ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
95
101
 
96
102
  - A CLIENT span named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
97
103
  system attribute (`db.system.name=redis`, `messaging.system=aws_sqs|resend|aws_ses`,
98
- `rpc.service=S3|KMS`, `rpc.system=vercel-blob`).
104
+ `rpc.service=S3|KMS|SecretsManager|BedrockRuntime`, `rpc.system=vercel-blob`, `rpc.system=kubernetes`;
105
+ Bedrock also `gen_ai.system=aws.bedrock`).
99
106
  - A counter and a duration histogram: `db.client.operation.*` (Redis, shared with SQL),
100
107
  `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3, Vercel Blob),
101
- `crypto.client.operation.*` (KMS), `http.client.request.*` (HTTP, and the HTTP request
108
+ `crypto.client.operation.*` (KMS), `secrets.client.operation.*` (Secrets Manager),
109
+ `llm.client.operation.*` and `llm.client.token.usage` (Bedrock), `compute.client.operation.*`
110
+ (Kubernetes Jobs),
111
+ `http.client.request.*` (HTTP, and the HTTP request
102
112
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
103
- - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
113
+ - Redis, SQS, QStash, S3, Vercel Blob, email, KMS, Secrets Manager, Bedrock and Kubernetes Jobs emit a `dependency.call_failed` warning on failure,
104
114
  with dependency, operation and error type only. HTTP/httpx2 also log failed attempts,
105
115
  including body failures and cancellation; QStash/Resend suppress the nested HTTP log.
106
116
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
@@ -109,7 +119,9 @@ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
109
119
  original type: they are bugs, not outages.
110
120
 
111
121
  Never in this library's spans, metrics or logs: keys, URLs, bodies, message ids or object
112
- names; email addresses, subjects, bodies or tags; KMS key ids, encryption contexts,
122
+ names; email addresses, subjects, bodies or tags; secret ids, version ids, stages and
123
+ values; model ids, prompts and outputs; namespaces, Job names, manifests and service
124
+ account tokens; KMS key ids, encryption contexts,
113
125
  plaintext, ciphertext or data keys. Use static dependency names such as `identity-provider` or `uploads`; never derive
114
126
  them from a tenant, user or request. All clients apply observability's identifier rule
115
127
  (1–96 letters, digits, `_`, `.` or `-`, starting with a letter or digit).
@@ -272,6 +284,64 @@ except EmailRejected as rejected: # the address, the domain or the credentials;
272
284
  SES has no idempotency key (the argument is ignored): deduplicate in the caller when a retry
273
285
  must not send twice. Tags are for provider analytics; never put personal data in them.
274
286
 
287
+ ### Kubernetes Jobs
288
+
289
+ ```python
290
+ from clients_021.compute.kubernetes import KubernetesJobsClient
291
+
292
+ jobs = KubernetesJobsClient.in_cluster(obs, namespace="agent-runs")
293
+ created = await jobs.create({"metadata": {"name": "cmd-4821"}, "spec": job_spec})
294
+ status = await jobs.get("cmd-4821") # JobStatus(phase="running", ...) or None
295
+ await jobs.delete("cmd-4821", propagation="Background")
296
+ ```
297
+
298
+ No SDK: it calls the `batch/v1` REST API through `HttpClient`. `in_cluster` uses the pod's
299
+ service account (CA bundle, namespace, and a token it re-reads at most once a minute because
300
+ projected tokens rotate). `create` is idempotent by name: `generateName` is refused, and an
301
+ existing Job returns `created=False`, so a caller can repeat it after a timeout. `get`
302
+ returns `None` and `delete` returns `False` for a missing Job. POST is sent once; GET and
303
+ DELETE retry 502, 503, 504 and network failures once. 429, 5xx, timeouts and connection
304
+ failures raise `DependencyUnavailable`; other rejections raise `KubernetesApiError` with the
305
+ status code and the API's reason, never its message.
306
+
307
+ ### Bedrock
308
+
309
+ ```python
310
+ from clients_021.llm.bedrock import BedrockRuntime
311
+ from langchain_aws import ChatBedrockConverse
312
+
313
+ runtime = BedrockRuntime.create(obs, region="us-east-1")
314
+ model = ChatBedrockConverse(model=settings.inference_profile, client=runtime)
315
+ ```
316
+
317
+ `BedrockRuntime` keeps boto3's call signatures for `converse`, `converse_stream`,
318
+ `invoke_model`, `invoke_model_with_response_stream` and `count_tokens`, and passes every
319
+ other attribute (`meta`, `exceptions`) through to the boto3 client, so an SDK can take it
320
+ where it expects one. Calls stay synchronous like boto3's. A streaming call is measured
321
+ until its event stream ends; leaving the loop early or calling `close()` ends it as `ok`.
322
+ Converse and ConverseStream add the reported input, output and cache tokens to
323
+ `llm.client.token.usage`. Throttling (also mid-stream), 5xx, a model still loading and the
324
+ stream errors Bedrock documents as retryable raise `DependencyUnavailable`; access denied,
325
+ validation errors, a missing model and quota limits keep their botocore type. A retried
326
+ model call is billed again.
327
+
328
+ ### Secrets Manager
329
+
330
+ ```python
331
+ from clients_021.secrets.secrets_manager import SecretsManagerClient
332
+
333
+ secrets = SecretsManagerClient.create(obs, region="us-east-1")
334
+ secret = await secrets.get("connections/brain")
335
+ token = secret.text() # SecretString; secret.binary() for SecretBinary
336
+ previous = await secrets.get("connections/brain", version_stage="AWSPREVIOUS")
337
+ ```
338
+
339
+ `get` reads the current version unless a `version_id` or a `version_stage` is given (not
340
+ both). The client does not cache: every call is a billed request, so keep a short-lived
341
+ copy when the same secret is read often. A missing secret, access denied, a value KMS cannot
342
+ decrypt or a secret scheduled for deletion keep their botocore type; throttling, 5xx and
343
+ timeouts raise `DependencyUnavailable`. `repr(secret)` never shows the value.
344
+
275
345
  ### KMS
276
346
 
277
347
  The encryption context is required on every call, so a ciphertext only decrypts for the
@@ -375,6 +445,8 @@ async with AsyncExitStack() as resources:
375
445
  resources.push_async_callback(email.aclose)
376
446
  keys = KmsClient.create(obs, settings.session_key_arn, region="us-east-1")
377
447
  resources.push_async_callback(keys.aclose)
448
+ secrets = SecretsManagerClient.create(obs, region="us-east-1")
449
+ resources.push_async_callback(secrets.aclose)
378
450
  # Run the service here. Stop its background workers before leaving this scope.
379
451
  ```
380
452
 
@@ -386,7 +458,7 @@ Retry policies are explicit:
386
458
  POST and PATCH run once. Invalid URLs/protocols and invalid request construction keep
387
459
  their original exception. HTTP status responses are returned for the caller to handle.
388
460
  - Resend sends each request once (POST); send an `idempotency_key` and retry in the caller.
389
- - AWS factories (SQS, S3, SES, KMS) use botocore standard retries with at most **three total
461
+ - AWS factories (SQS, S3, SES, KMS, Secrets Manager, Bedrock) use botocore standard retries with at most **three total
390
462
  attempts**.
391
463
  Injected SDK clients keep their configured policy. SQS consumers must tolerate duplicate
392
464
  messages; a timeout does not prove that a send or write was not applied.
@@ -409,7 +481,7 @@ any operation**. Map it at the service boundary and apply the operation's idempo
409
481
  Its message and structured fields are safe to log when dependency names are static. Its
410
482
  chained SDK exception may contain sensitive provider data; do not log exception chains.
411
483
 
412
- S3, SQS, SES and KMS run the synchronous AWS SDK in worker threads. Cancelling the await does not
484
+ S3, SQS, SES, KMS and Secrets Manager run the synchronous AWS SDK in worker threads. Cancelling the await does not
413
485
  stop an SDK request already running in its thread. Timeouts are per SDK/network attempt,
414
486
  not an overall deadline. Limit consumer concurrency and let in-flight work finish before
415
487
  closing the clients. Redis window counters require Redis server 7 or newer (`EXPIRE NX`).
@@ -433,6 +505,8 @@ deduplication, start the [official local server](https://upstash.com/docs/qstash
433
505
  set its `QSTASH_URL` and `QSTASH_TOKEN`, and run `uv run python tests/qstash_local.py`.
434
506
  That smoke test accepts only a loopback QStash server and binds its destination dynamically.
435
507
 
436
- SQS, S3, SES and KMS tests run against [moto](https://github.com/getmoto/moto) (plus
508
+ Kubernetes Jobs tests use httpx's `MockTransport`. Bedrock tests use botocore's Stubber and
509
+ fake event streams. SQS, S3, SES, KMS and Secrets
510
+ Manager tests run against [moto](https://github.com/getmoto/moto) (plus
437
511
  botocore's Stubber where moto ignores a field or differs from AWS); Resend tests use httpx's
438
512
  `MockTransport`; Redis tests need a real Redis. Licensed under Apache-2.0.
@@ -1,6 +1,6 @@
1
- clients_021/__init__.py,sha256=to7x0AyCtQxhss2a4YGj1GMJq8NyRQdKp-DICQrBbzs,821
1
+ clients_021/__init__.py,sha256=D7i865mBaxw32ycKWsMazMRljtYFdabdSG8dHjfWg3M,921
2
2
  clients_021/_aws.py,sha256=sj2kCfod-vz9J93UyeVzNVLGK3M25yipKdW15KyIkw4,1722
3
- clients_021/_observe.py,sha256=Xjg5Qck-e8Gt5NQHOND6nzr7DvTn46PmBdccubiD3Dk,3792
3
+ clients_021/_observe.py,sha256=Kh-2gitT2bI4vNa-Y7PYsCns60XJzJ_N7ZbTuyX82lo,6977
4
4
  clients_021/errors.py,sha256=GS_fN8mjr7MCOkhqPhH6LweIvQ20Vd29U-aZY8zBeJQ,972
5
5
  clients_021/httpx2.py,sha256=rNbNGJuPJPZUwVbNN5L88IF0g3Tq6EHqj_uFmJ3RmLQ,161
6
6
  clients_021/kms.py,sha256=YcZXURpqIZTkjcP0fpWMD6X_adMAYwN7zAJNhUImR4s,183
@@ -9,6 +9,8 @@ clients_021/redis.py,sha256=xC3tji_ldJAwaBntkZTuPhNHLIzl3F3nDzkikMrbJCc,169
9
9
  clients_021/s3.py,sha256=6Q5_1emngSB00IwvBdrmV3iAg-Q_Ndco1HGzYObL5FI,231
10
10
  clients_021/cache/__init__.py,sha256=3OyQQ-uZeghpgJvvJ2k56_t50q3CCwbAp3dBrCMMcSU,76
11
11
  clients_021/cache/redis.py,sha256=DRXCMc8MqY8KsZ50j4X4mUsd0lvvmP-UDuUD2wJXyW8,4932
12
+ clients_021/compute/__init__.py,sha256=PP23LVKxoJGkwzzFbuKMRd35LugdYqJWElp9lWymllM,78
13
+ clients_021/compute/kubernetes.py,sha256=a0-9ldOg1mQjJBVzWAhZH4QGEN6qAzcgl_n8Ty0rSts,12456
12
14
  clients_021/crypto/__init__.py,sha256=qESFBPQBKcHhdFXqeJzSyEP41yveG7rFrxnpITmqlNU,77
13
15
  clients_021/crypto/kms.py,sha256=My5Xyg0bOiT9YHYWojQSszpREEh3bSYMMB26wy6fNEI,7261
14
16
  clients_021/email/__init__.py,sha256=ivmdu9hhNDwPqBn3G7uNG0Zpc3DEMnlnWBVVoVQ-fQA,790
@@ -19,13 +21,17 @@ clients_021/http/__init__.py,sha256=P4OmYruhkQfJwJzHA90FXKWg_A2rh5iOl-ES_NTMMVQ,
19
21
  clients_021/http/_observe.py,sha256=hpTzcej9MhlvM-RgUZWicR57ulE90GmZMs5U_qFoquQ,6922
20
22
  clients_021/http/httpx.py,sha256=gvqSpO1rb6nBA5i01W1yos5mPBwLqa3-HDYwMtQXc6s,10627
21
23
  clients_021/http/httpx2.py,sha256=fmqH4bikDKZ6YY0sNBHZbocff2zU40Gpks1aoRuOGeQ,2810
24
+ clients_021/llm/__init__.py,sha256=pEI4drpN0MKg_gp8BP1U7kpVd3NDV_Sa9eFD3J87LPw,86
25
+ clients_021/llm/bedrock.py,sha256=GJafCh6PhBrsDgs_GapoKiVflv32V7BUqyAj52pMQiA,9367
22
26
  clients_021/queue/__init__.py,sha256=CesweOrXmX5cyU-mRvqu-4wLkNJI15zi_Lows6SPupY,338
23
27
  clients_021/queue/qstash.py,sha256=NndlqmNkcBgs0ExGsT-3-bMAdU0skO8Djptqj8NoJAM,8335
24
28
  clients_021/queue/sqs.py,sha256=jnccgbxyglzs2mR_DsH04IisEwygDlw6s3RxR0UtMTc,6693
29
+ clients_021/secrets/__init__.py,sha256=LdxphQvruITeP5jXZR69ly2jxMxuHCI1BDavw2mU0EY,76
30
+ clients_021/secrets/secrets_manager.py,sha256=ttP3_vCXRtOYvbUaNIkFlSTzYYr4yU3Uwim3juXUfPM,4746
25
31
  clients_021/storage/__init__.py,sha256=KrJViRHBK7PkIYnagg7vyXp95SzMFp9cUCKEnMeFr84,78
26
32
  clients_021/storage/s3.py,sha256=NDtG9QzHSXvrcazTYTq1EH5VjuRd2xMMsMvaumqci4Q,5946
27
33
  clients_021/storage/vercel_blob.py,sha256=FlnoKiahqtk3PpLsGpyu_Gy87otg15i4BlJZsIB3LfM,6511
28
- 021_python_clients-0.3.0.dist-info/METADATA,sha256=ewZEC_EJC4-e8ZayWgpdlnxQ0hYhWMMjmRr2KIN4iW4,23227
29
- 021_python_clients-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
30
- 021_python_clients-0.3.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
31
- 021_python_clients-0.3.0.dist-info/RECORD,,
34
+ 021_python_clients-0.4.0.dist-info/METADATA,sha256=Q4ITtnJ9tRJxxH3gdoE-1nl3A4ClHkAV5yihBGN1JTg,27808
35
+ 021_python_clients-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
36
+ 021_python_clients-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
37
+ 021_python_clients-0.4.0.dist-info/RECORD,,
clients_021/__init__.py CHANGED
@@ -1,8 +1,10 @@
1
1
  """Observed clients for the external services Python services call.
2
2
 
3
- `clients_021.http`, `clients_021.queue.qstash` and `clients_021.email` (Resend) need no extras;
3
+ `clients_021.http`, `clients_021.queue.qstash`, `clients_021.email` (Resend) and
4
+ `clients_021.compute.kubernetes` need no extras;
4
5
  `clients_021.cache.redis` needs the `redis` extra; `clients_021.queue.sqs`,
5
- `clients_021.storage.s3`, `clients_021.email.ses` and `clients_021.crypto.kms` the `aws` extra;
6
+ `clients_021.storage.s3`, `clients_021.email.ses`, `clients_021.crypto.kms`,
7
+ `clients_021.secrets.secrets_manager` and `clients_021.llm.bedrock` the `aws` extra;
6
8
  `clients_021.http.httpx2` the `httpx2` extra.
7
9
  `clients_021.storage.vercel_blob` needs the `vercel-blob` extra.
8
10
  """
clients_021/_observe.py CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  The `db` family reuses the instruments 021-python-observability already creates for SQL
4
4
  (`db.client.operation.*`), so Redis and Postgres share dashboards. `messaging` (SQS, email),
5
- `storage` (S3, Vercel Blob) and `crypto` (KMS) get their own pair on the same meter. Attributes are
6
- bounded: dependency, operation, status and error.type; never keys, URLs, bodies or ids.
5
+ `storage` (S3, Vercel Blob), `crypto` (KMS), `secrets` (Secrets Manager), `llm` (Bedrock) and
6
+ `compute` (Kubernetes Jobs) get their own pair on the same meter. `Call` is the same
7
+ measurement for synchronous SDK calls and for operations that end later than they start, such
8
+ as a model response stream. Attributes are bounded: dependency, operation, status and
9
+ error.type; never keys, URLs, bodies or ids.
7
10
  """
8
11
 
9
12
  from __future__ import annotations
@@ -12,7 +15,7 @@ import asyncio
12
15
  import time
13
16
  from collections.abc import AsyncGenerator, Callable
14
17
  from contextlib import asynccontextmanager
15
- from typing import Literal
18
+ from typing import Literal, NoReturn
16
19
  from weakref import WeakKeyDictionary
17
20
 
18
21
  from observability_021 import Observability, get_logger
@@ -21,7 +24,7 @@ from opentelemetry.trace import SpanKind, StatusCode
21
24
 
22
25
  from clients_021.errors import DependencyUnavailable
23
26
 
24
- Family = Literal["db", "messaging", "storage", "crypto"]
27
+ Family = Literal["db", "messaging", "storage", "crypto", "secrets", "llm", "compute"]
25
28
  # Returns the reason when an exception means "the dependency failed", None for a bug.
26
29
  Classifier = Callable[[BaseException], str | None]
27
30
 
@@ -31,6 +34,9 @@ _UNITS = {
31
34
  "messaging": "{operation}",
32
35
  "storage": "{operation}",
33
36
  "crypto": "{operation}",
37
+ "secrets": "{operation}",
38
+ "llm": "{operation}",
39
+ "compute": "{operation}",
34
40
  }
35
41
  _cache: WeakKeyDictionary[Observability, dict[Family, tuple[Counter, Histogram]]] = (
36
42
  WeakKeyDictionary()
@@ -104,3 +110,83 @@ async def observed(
104
110
  finally:
105
111
  count.add(1, attributes)
106
112
  duration.record(time.perf_counter() - started, attributes)
113
+
114
+
115
+ class Call:
116
+ """One observed operation started now and finished by `ok`, `fail` or `cancelled`.
117
+
118
+ The synchronous counterpart of `observed`, with the same span, instruments, labels and
119
+ warning. It also covers operations that outlive the call that started them: a stream is
120
+ finished when it is exhausted, fails or is closed. Finishing twice is ignored.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ obs: Observability,
126
+ *,
127
+ family: Family,
128
+ system: dict[str, str],
129
+ dependency: str,
130
+ operation: str,
131
+ classify: Classifier,
132
+ ) -> None:
133
+ self._attributes: dict[str, str] = {"dependency": dependency, "operation": operation}
134
+ self._count, self._duration = instruments(obs, family)
135
+ self._classify = classify
136
+ self._started = time.perf_counter()
137
+ self._span = obs.tracer.start_span(
138
+ f"{dependency} {operation}",
139
+ kind=SpanKind.CLIENT,
140
+ attributes={**system, **self._attributes},
141
+ record_exception=False,
142
+ set_status_on_exception=False,
143
+ )
144
+ self._done = False
145
+
146
+ @property
147
+ def done(self) -> bool:
148
+ return self._done
149
+
150
+ def _finish(self) -> None:
151
+ self._done = True
152
+ self._span.set_attributes(self._attributes)
153
+ self._span.end()
154
+ self._count.add(1, self._attributes)
155
+ self._duration.record(time.perf_counter() - self._started, self._attributes)
156
+
157
+ def ok(self) -> None:
158
+ if self._done:
159
+ return
160
+ self._attributes["status"] = "ok"
161
+ self._finish()
162
+
163
+ def cancelled(self) -> None:
164
+ if self._done:
165
+ return
166
+ self._attributes["status"] = "error"
167
+ self._attributes["error.type"] = "cancelled"
168
+ self._span.set_status(StatusCode.ERROR)
169
+ self._finish()
170
+
171
+ def fail(self, error: BaseException) -> NoReturn:
172
+ """Records the failure, then raises DependencyUnavailable or the error itself."""
173
+ if isinstance(error, asyncio.CancelledError | KeyboardInterrupt | GeneratorExit):
174
+ self.cancelled()
175
+ raise error
176
+ reason = self._classify(error)
177
+ if not self._done:
178
+ self._attributes["status"] = "error"
179
+ self._attributes["error.type"] = reason or type(error).__name__
180
+ self._span.set_status(StatusCode.ERROR)
181
+ logger.warning(
182
+ "dependency.call_failed",
183
+ dependency=self._attributes["dependency"],
184
+ operation=self._attributes["operation"],
185
+ error_type=self._attributes["error.type"],
186
+ )
187
+ self._finish()
188
+ if reason is None:
189
+ raise error
190
+ raise DependencyUnavailable(
191
+ self._attributes["dependency"], self._attributes["operation"], reason
192
+ ) from error
@@ -0,0 +1 @@
1
+ """Compute clients. Import providers explicitly; SDKs are optional extras."""
@@ -0,0 +1,314 @@
1
+ """Kubernetes Jobs over the shared HTTP client: create, read and delete Jobs in one namespace.
2
+
3
+ from clients_021.compute.kubernetes import KubernetesJobsClient
4
+
5
+ jobs = KubernetesJobsClient.in_cluster(obs, namespace="agent-runs")
6
+ created = await jobs.create(manifest) # created.created is False if it already existed
7
+ status = await jobs.get("cmd-4821") # None when the Job does not exist
8
+ deleted = await jobs.delete("cmd-4821")
9
+
10
+ No optional SDK: it speaks the `batch/v1` REST API through `HttpClient`. `in_cluster` reads
11
+ the pod's service account (API host from `KUBERNETES_SERVICE_HOST`/`_PORT`, the CA bundle and
12
+ the namespace from `/var/run/secrets/kubernetes.io/serviceaccount`) and re-reads the token
13
+ file at most once a minute, because projected tokens rotate. Calls record
14
+ `compute.client.operation.*` with `operation` (`create`, `get`, `delete`) and a span with
15
+ `rpc.system=kubernetes`, `rpc.service=Job`; never namespaces, Job names, manifests, tokens
16
+ or URLs. The HTTP request under each operation is also measured by the transport.
17
+
18
+ `create` is idempotent by name: a Job is named by its manifest (`generateName` is refused),
19
+ and a 409 for an existing name returns `created=False` instead of failing, so a caller that
20
+ got a timeout can call it again. POST is sent once; GET and DELETE retry 502, 503, 504 and
21
+ network failures once. Timeouts, connection failures, 429 and 5xx raise
22
+ DependencyUnavailable. Other rejections (401, 403, 404 on create, 422) raise
23
+ `KubernetesApiError` with the status code and the API's machine-readable reason, never the
24
+ message or the manifest.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import ssl
32
+ import time
33
+ from collections.abc import Callable, Mapping
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any, Literal, cast
37
+ from urllib.parse import quote
38
+
39
+ import httpx
40
+ from observability_021 import Observability
41
+ from observability_021.config import identifier
42
+
43
+ from clients_021._observe import observed
44
+ from clients_021.errors import DependencyUnavailable
45
+ from clients_021.http import HttpClient, HttpResponse
46
+
47
+ SYSTEM = {"rpc.system": "kubernetes", "rpc.service": "Job"}
48
+ SERVICE_ACCOUNT = Path("/var/run/secrets/kubernetes.io/serviceaccount")
49
+ _TOKEN_TTL_SECONDS = 60.0
50
+ _DNS_LABEL = 63
51
+ Phase = Literal["pending", "running", "succeeded", "failed"]
52
+ Propagation = Literal["Background", "Foreground", "Orphan"]
53
+
54
+
55
+ class KubernetesApiError(Exception):
56
+ """A rejected request, with the status code and the API's reason, never its message."""
57
+
58
+ def __init__(self, status_code: int, reason: str) -> None:
59
+ super().__init__(f"Kubernetes API: {reason} (HTTP {status_code})")
60
+ self.status_code = status_code
61
+ self.reason = reason
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class JobCreated:
66
+ name: str
67
+ created: bool
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class JobStatus:
72
+ name: str
73
+ phase: Phase
74
+ active: int
75
+ succeeded: int
76
+ failed: int
77
+
78
+
79
+ def _classify(error: BaseException) -> str | None:
80
+ if isinstance(error, DependencyUnavailable):
81
+ return error.reason
82
+ if isinstance(error, KubernetesApiError):
83
+ if error.status_code == 429:
84
+ return "throttled"
85
+ if 500 <= error.status_code < 600:
86
+ return f"status_{error.status_code}"
87
+ return None
88
+
89
+
90
+ def _name(value: str, *, field: str = "name") -> str:
91
+ if (
92
+ not value
93
+ or len(value) > _DNS_LABEL
94
+ or not all(
95
+ char.isascii() and (char.islower() or char.isdigit() or char == "-") for char in value
96
+ )
97
+ or value[0] == "-"
98
+ or value[-1] == "-"
99
+ ):
100
+ raise ValueError(f"{field} must be a DNS label (lowercase letters, digits and '-')")
101
+ return value
102
+
103
+
104
+ def _json(response: HttpResponse) -> dict[str, Any]:
105
+ try:
106
+ data: Any = response.json()
107
+ except (ValueError, UnicodeDecodeError):
108
+ raise KubernetesApiError(response.status_code, "InvalidResponse") from None
109
+ if not isinstance(data, dict):
110
+ raise KubernetesApiError(response.status_code, "InvalidResponse")
111
+ return cast(dict[str, Any], data)
112
+
113
+
114
+ def _rejected(response: HttpResponse) -> KubernetesApiError:
115
+ reason = "Unknown"
116
+ try:
117
+ data: Any = response.json()
118
+ if isinstance(data, dict):
119
+ value = cast(dict[str, Any], data).get("reason")
120
+ if isinstance(value, str) and value.isalnum() and len(value) <= 64:
121
+ reason = value
122
+ except (ValueError, UnicodeDecodeError):
123
+ pass
124
+ return KubernetesApiError(response.status_code, reason)
125
+
126
+
127
+ def _count(status: Mapping[str, Any], field: str) -> int:
128
+ value = status.get(field, 0)
129
+ return value if isinstance(value, int) and value >= 0 else 0
130
+
131
+
132
+ def _status(name: str, job: Mapping[str, Any]) -> JobStatus:
133
+ raw: Any = job.get("status") or {}
134
+ status = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
135
+ conditions: Any = status.get("conditions") or []
136
+ true_types: set[str] = set()
137
+ if isinstance(conditions, list):
138
+ for condition in cast(list[Any], conditions):
139
+ if isinstance(condition, dict):
140
+ entry = cast(dict[str, Any], condition)
141
+ if entry.get("status") == "True" and isinstance(entry.get("type"), str):
142
+ true_types.add(cast(str, entry["type"]))
143
+ active, succeeded, failed = (_count(status, f) for f in ("active", "succeeded", "failed"))
144
+ phase: Phase
145
+ if "Complete" in true_types or "SuccessCriteriaMet" in true_types:
146
+ phase = "succeeded"
147
+ elif "Failed" in true_types or "FailureTarget" in true_types:
148
+ phase = "failed"
149
+ elif active > 0:
150
+ phase = "running"
151
+ else:
152
+ phase = "pending"
153
+ return JobStatus(name=name, phase=phase, active=active, succeeded=succeeded, failed=failed)
154
+
155
+
156
+ class _FileToken:
157
+ """Reads a projected service account token, at most once per TTL."""
158
+
159
+ def __init__(self, path: Path, *, ttl_seconds: float = _TOKEN_TTL_SECONDS) -> None:
160
+ self._path = path
161
+ self._ttl = ttl_seconds
162
+ self._value = ""
163
+ self._read_at = float("-inf")
164
+
165
+ def __call__(self) -> str:
166
+ now = time.monotonic()
167
+ if now - self._read_at >= self._ttl:
168
+ self._value = self._path.read_text().strip()
169
+ self._read_at = now
170
+ return self._value
171
+
172
+
173
+ class KubernetesJobsClient:
174
+ def __init__(
175
+ self,
176
+ obs: Observability,
177
+ *,
178
+ api_url: str,
179
+ namespace: str,
180
+ token: Callable[[], str],
181
+ timeout_seconds: float = 10.0,
182
+ dependency: str = "kubernetes",
183
+ transport: httpx.AsyncBaseTransport | None = None,
184
+ ) -> None:
185
+ if not api_url.startswith(("https://", "http://")):
186
+ raise ValueError("api_url must be an HTTP(S) URL")
187
+ self.dependency = identifier(dependency)
188
+ self._obs = obs
189
+ namespace = quote(_name(namespace, field="namespace"))
190
+ self._jobs_url = f"{api_url.rstrip('/')}/apis/batch/v1/namespaces/{namespace}/jobs"
191
+ self._token = token
192
+ self._closed = False
193
+ self._http = HttpClient(
194
+ obs, timeout_seconds=timeout_seconds, retries=1, log_failures=False, transport=transport
195
+ )
196
+
197
+ @classmethod
198
+ def in_cluster(
199
+ cls,
200
+ obs: Observability,
201
+ *,
202
+ namespace: str | None = None,
203
+ timeout_seconds: float = 10.0,
204
+ dependency: str = "kubernetes",
205
+ service_account: Path = SERVICE_ACCOUNT,
206
+ ) -> KubernetesJobsClient:
207
+ """The pod's own service account; fails before any request if it is not mounted."""
208
+ host = os.environ.get("KUBERNETES_SERVICE_HOST")
209
+ port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
210
+ if not host:
211
+ raise RuntimeError("KUBERNETES_SERVICE_HOST is not set: not running in a pod")
212
+ if ":" in host:
213
+ host = f"[{host}]"
214
+ ca = service_account / "ca.crt"
215
+ token = service_account / "token"
216
+ if not ca.is_file() or not token.is_file():
217
+ raise RuntimeError("the service account is not mounted")
218
+ if namespace is None:
219
+ namespace = (service_account / "namespace").read_text().strip()
220
+ transport = httpx.AsyncHTTPTransport(verify=ssl.create_default_context(cafile=str(ca)))
221
+ return cls(
222
+ obs,
223
+ api_url=f"https://{host}:{port}",
224
+ namespace=namespace,
225
+ token=_FileToken(token),
226
+ timeout_seconds=timeout_seconds,
227
+ dependency=dependency,
228
+ transport=transport,
229
+ )
230
+
231
+ def _headers(self) -> dict[str, str]:
232
+ return {"Authorization": f"Bearer {self._token()}", "Accept": "application/json"}
233
+
234
+ def _observed(self, operation: str): # noqa: ANN202 - an async context manager
235
+ if self._closed:
236
+ raise RuntimeError("KubernetesJobsClient is closed")
237
+ return observed(
238
+ self._obs,
239
+ family="compute",
240
+ system=SYSTEM,
241
+ dependency=self.dependency,
242
+ operation=operation,
243
+ classify=_classify,
244
+ )
245
+
246
+ async def create(self, manifest: Mapping[str, Any]) -> JobCreated:
247
+ """Creates the Job named by `metadata.name`; an existing Job with that name is kept."""
248
+ metadata: Any = manifest.get("metadata")
249
+ if not isinstance(metadata, dict):
250
+ raise ValueError("manifest.metadata is required")
251
+ fields = cast(dict[str, Any], metadata)
252
+ if "generateName" in fields:
253
+ raise ValueError("generateName is not idempotent; set metadata.name")
254
+ name = fields.get("name")
255
+ if not isinstance(name, str):
256
+ raise ValueError("manifest.metadata.name is required")
257
+ _name(name)
258
+ if manifest.get("kind", "Job") != "Job":
259
+ raise ValueError("manifest.kind must be Job")
260
+ body = {"apiVersion": "batch/v1", "kind": "Job", **manifest}
261
+ content = json.dumps(body, separators=(",", ":"), allow_nan=False).encode()
262
+ async with self._observed("create"):
263
+ response = await self._http.request(
264
+ "POST",
265
+ self._jobs_url,
266
+ dependency=self.dependency,
267
+ headers={**self._headers(), "Content-Type": "application/json"},
268
+ content=content,
269
+ )
270
+ if response.status_code == 409 and _rejected(response).reason == "AlreadyExists":
271
+ return JobCreated(name=name, created=False)
272
+ if response.status_code != 201:
273
+ raise _rejected(response)
274
+ created: Any = _json(response).get("metadata")
275
+ if not isinstance(created, dict) or cast(dict[str, Any], created).get("name") != name:
276
+ raise KubernetesApiError(response.status_code, "InvalidResponse")
277
+ return JobCreated(name=name, created=True)
278
+
279
+ async def get(self, name: str) -> JobStatus | None:
280
+ url = f"{self._jobs_url}/{quote(_name(name))}"
281
+ async with self._observed("get"):
282
+ response = await self._http.request(
283
+ "GET", url, dependency=self.dependency, headers=self._headers()
284
+ )
285
+ if response.status_code == 404:
286
+ return None
287
+ if response.status_code != 200:
288
+ raise _rejected(response)
289
+ return _status(name, _json(response))
290
+
291
+ async def delete(self, name: str, *, propagation: Propagation = "Background") -> bool:
292
+ """True when a Job was deleted, False when there was none; its pods follow `propagation`."""
293
+ url = f"{self._jobs_url}/{quote(_name(name))}"
294
+ content = json.dumps(
295
+ {"kind": "DeleteOptions", "apiVersion": "v1", "propagationPolicy": propagation}
296
+ ).encode()
297
+ async with self._observed("delete"):
298
+ response = await self._http.request(
299
+ "DELETE",
300
+ url,
301
+ dependency=self.dependency,
302
+ headers={**self._headers(), "Content-Type": "application/json"},
303
+ content=content,
304
+ )
305
+ if response.status_code == 404:
306
+ return False
307
+ if response.status_code not in (200, 202):
308
+ raise _rejected(response)
309
+ return True
310
+
311
+ async def aclose(self) -> None:
312
+ """Release the HTTP pool and injected transport after in-flight calls finish."""
313
+ self._closed = True
314
+ await self._http.aclose()
@@ -0,0 +1 @@
1
+ """Model inference clients. Import providers explicitly; SDKs are optional extras."""
@@ -0,0 +1,250 @@
1
+ """Amazon Bedrock Runtime, observed, for SDKs that take a boto3 client.
2
+
3
+ from clients_021.llm.bedrock import BedrockRuntime
4
+ from langchain_aws import ChatBedrockConverse
5
+
6
+ runtime = BedrockRuntime.create(obs, region="us-east-1")
7
+ model = ChatBedrockConverse(model=settings.inference_profile, client=runtime)
8
+
9
+ `BedrockRuntime` has the same call signatures as boto3's `bedrock-runtime` client for
10
+ `converse`, `converse_stream`, `invoke_model`, `invoke_model_with_response_stream` and
11
+ `count_tokens`, and passes every other attribute (`meta`, `exceptions`, ...) through, so an
12
+ SDK can use it where it expects the boto3 client. Those five operations record
13
+ `llm.client.operation.*` and a span with `gen_ai.system=aws.bedrock`. Calls are synchronous,
14
+ like boto3's; an SDK that is async runs them in its own worker threads.
15
+
16
+ A streaming operation lasts until its event stream is exhausted, fails or is closed: the
17
+ response's `stream` (Converse) or `body` (InvokeModel) is wrapped, and closing it early ends
18
+ the operation as `ok` with a `dependency.stream_closed` log. Converse and ConverseStream also
19
+ add the reported token usage to `llm.client.token.usage` (`token.type`: `input`, `output`,
20
+ `cache_read`, `cache_write`); InvokeModel bodies are model-specific and are not read.
21
+
22
+ Timeouts, connection failures, throttling (also mid-stream), 5xx and the stream errors
23
+ Bedrock documents as retryable (a model still loading included) raise
24
+ DependencyUnavailable. Access denied, validation errors, a missing model and quota errors
25
+ that need a configuration change keep their botocore type. botocore's standard retry mode
26
+ makes up to 3 attempts per call: a retried model call is billed again. Never recorded:
27
+ model ids or ARNs, prompts, outputs, tool arguments, request ids or guardrail details.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ from collections.abc import Callable, Iterator
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ import boto3
37
+ from botocore.exceptions import ClientError
38
+ from observability_021 import Observability, get_logger
39
+ from observability_021.config import identifier
40
+
41
+ from clients_021 import _aws
42
+ from clients_021._observe import Call
43
+
44
+ if TYPE_CHECKING:
45
+ from mypy_boto3_bedrock_runtime import BedrockRuntimeClient
46
+ else:
47
+ BedrockRuntimeClient = Any
48
+
49
+ SYSTEM = {"rpc.system": "aws-api", "rpc.service": "BedrockRuntime", "gen_ai.system": "aws.bedrock"}
50
+ # Exception names Bedrock sends inside an event stream (lower camel case) and the HTTP ones it
51
+ # documents as retryable. Their status code is not always in the response metadata.
52
+ _STREAM_RETRYABLE = {
53
+ "throttlingException": "throttled",
54
+ "serviceUnavailableException": "status_503",
55
+ "internalServerException": "status_500",
56
+ "modelStreamErrorException": "status_500",
57
+ "ServiceUnavailableException": "status_503",
58
+ "InternalServerException": "status_500",
59
+ "ModelStreamErrorException": "status_500",
60
+ # The model is still loading; Bedrock documents it as safe to retry.
61
+ "ModelNotReadyException": "throttled",
62
+ }
63
+ _USAGE = {
64
+ "inputTokens": "input",
65
+ "outputTokens": "output",
66
+ "cacheReadInputTokens": "cache_read",
67
+ "cacheWriteInputTokens": "cache_write",
68
+ }
69
+
70
+ logger = get_logger(__name__)
71
+
72
+
73
+ def classify(error: BaseException) -> str | None:
74
+ reason = _aws.classify(error)
75
+ if reason is None and isinstance(error, ClientError):
76
+ reason = _STREAM_RETRYABLE.get(_aws.error_code(error))
77
+ return reason
78
+
79
+
80
+ class _ObservedStream:
81
+ """An event stream whose operation ends when it is exhausted, fails or is closed.
82
+
83
+ Leaving the loop early (`break`) or calling `close()` is a deliberate partial read: the
84
+ operation ends as `ok` and `dependency.stream_closed` is logged at INFO.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ inner: Any,
90
+ call: Call,
91
+ *,
92
+ dependency: str,
93
+ operation: str,
94
+ on_event: Callable[[Any], None] | None,
95
+ ) -> None:
96
+ self._inner = inner
97
+ self._call = call
98
+ self._dependency = dependency
99
+ self._operation = operation
100
+ self._on_event = on_event
101
+
102
+ def __iter__(self) -> Iterator[Any]:
103
+ try:
104
+ for event in self._inner:
105
+ if self._on_event is not None:
106
+ self._on_event(event)
107
+ yield event
108
+ except GeneratorExit:
109
+ self._closed_early()
110
+ raise
111
+ except BaseException as error:
112
+ self._call.fail(error)
113
+ self._call.ok()
114
+
115
+ def _closed_early(self) -> None:
116
+ if not self._call.done:
117
+ logger.info(
118
+ "dependency.stream_closed", dependency=self._dependency, operation=self._operation
119
+ )
120
+ self._call.ok()
121
+
122
+ def close(self) -> None:
123
+ try:
124
+ self._inner.close()
125
+ finally:
126
+ self._closed_early()
127
+
128
+ def __getattr__(self, name: str) -> Any:
129
+ return getattr(self._inner, name)
130
+
131
+
132
+ class BedrockRuntime:
133
+ def __init__(
134
+ self, obs: Observability, client: BedrockRuntimeClient, *, dependency: str = "bedrock"
135
+ ) -> None:
136
+ self._obs = obs
137
+ self._client = client
138
+ self.dependency = identifier(dependency)
139
+ self._tokens = obs.meter.create_counter("llm.client.token.usage", unit="{token}")
140
+
141
+ @classmethod
142
+ def create(
143
+ cls,
144
+ obs: Observability,
145
+ *,
146
+ region: str | None = None,
147
+ endpoint_url: str | None = None,
148
+ dependency: str = "bedrock",
149
+ connect_seconds: float = 3.0,
150
+ read_seconds: float = 300.0,
151
+ ) -> BedrockRuntime:
152
+ """`read_seconds` bounds each wait for bytes; a long generation streams for longer."""
153
+ client: BedrockRuntimeClient = boto3.client( # pyright: ignore[reportUnknownMemberType]
154
+ "bedrock-runtime",
155
+ region_name=region,
156
+ endpoint_url=endpoint_url,
157
+ config=_aws.config(connect_seconds=connect_seconds, read_seconds=read_seconds),
158
+ )
159
+ return cls(obs, client, dependency=dependency)
160
+
161
+ def _call(self, operation: str) -> Call:
162
+ return Call(
163
+ self._obs,
164
+ family="llm",
165
+ system=SYSTEM,
166
+ dependency=self.dependency,
167
+ operation=operation,
168
+ classify=classify,
169
+ )
170
+
171
+ def _record_usage(self, operation: str, usage: Any) -> None:
172
+ if not isinstance(usage, dict):
173
+ return
174
+ for field, token_type in _USAGE.items():
175
+ value: Any = usage.get(field) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
176
+ if isinstance(value, int) and value > 0:
177
+ self._tokens.add(
178
+ value,
179
+ {
180
+ "dependency": self.dependency,
181
+ "operation": operation,
182
+ "token.type": token_type,
183
+ },
184
+ )
185
+
186
+ def _unary(self, operation: str, method: Callable[..., Any], params: dict[str, Any]) -> Any:
187
+ call = self._call(operation)
188
+ try:
189
+ response = method(**params)
190
+ except BaseException as error:
191
+ call.fail(error)
192
+ call.ok()
193
+ if operation == "converse":
194
+ self._record_usage(operation, response.get("usage"))
195
+ return response
196
+
197
+ def _streaming(
198
+ self, operation: str, method: Callable[..., Any], params: dict[str, Any], key: str
199
+ ) -> Any:
200
+ call = self._call(operation)
201
+ try:
202
+ response = method(**params)
203
+ except BaseException as error:
204
+ call.fail(error)
205
+
206
+ def on_event(event: Any) -> None:
207
+ metadata: Any = None
208
+ if isinstance(event, dict):
209
+ metadata = event.get("metadata") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
210
+ if isinstance(metadata, dict):
211
+ self._record_usage(operation, metadata.get("usage")) # pyright: ignore[reportUnknownMemberType]
212
+
213
+ stream = _ObservedStream(
214
+ response[key],
215
+ call,
216
+ dependency=self.dependency,
217
+ operation=operation,
218
+ on_event=on_event if operation == "converse_stream" else None,
219
+ )
220
+ return {**response, key: stream}
221
+
222
+ def converse(self, **params: Any) -> Any:
223
+ return self._unary("converse", self._client.converse, params)
224
+
225
+ def converse_stream(self, **params: Any) -> Any:
226
+ return self._streaming("converse_stream", self._client.converse_stream, params, "stream")
227
+
228
+ def invoke_model(self, **params: Any) -> Any:
229
+ return self._unary("invoke_model", self._client.invoke_model, params)
230
+
231
+ def invoke_model_with_response_stream(self, **params: Any) -> Any:
232
+ return self._streaming(
233
+ "invoke_model_with_response_stream",
234
+ self._client.invoke_model_with_response_stream,
235
+ params,
236
+ "body",
237
+ )
238
+
239
+ def count_tokens(self, **params: Any) -> Any:
240
+ return self._unary("count_tokens", self._client.count_tokens, params)
241
+
242
+ def close(self) -> None:
243
+ self._client.close()
244
+
245
+ async def aclose(self) -> None:
246
+ """Release the underlying SDK connection pools after in-flight calls finish."""
247
+ await asyncio.to_thread(self._client.close)
248
+
249
+ def __getattr__(self, name: str) -> Any:
250
+ return getattr(self._client, name)
@@ -0,0 +1 @@
1
+ """Secret stores. Import providers explicitly; SDKs are optional extras."""
@@ -0,0 +1,127 @@
1
+ """AWS Secrets Manager, observed: read one secret value by id, version id or stage.
2
+
3
+ from clients_021.secrets.secrets_manager import SecretsManagerClient
4
+
5
+ secrets = SecretsManagerClient.create(obs, region="us-east-1")
6
+ secret = await secrets.get("connections/brain")
7
+ token = secret.text() # SecretString; secret.binary() for SecretBinary
8
+ previous = await secrets.get("connections/brain", version_stage="AWSPREVIOUS")
9
+
10
+ Needs the `aws` extra. Calls record `secrets.client.operation.*` with `operation=get` and a
11
+ span with `rpc.service=SecretsManager`; never secret ids, ARNs, version ids, stages or values.
12
+ Timeouts, connection failures, throttling and 5xx raise DependencyUnavailable. A missing
13
+ secret (`ResourceNotFoundException`), access denied, a secret KMS cannot decrypt
14
+ (`DecryptionFailure`) or one scheduled for deletion (`InvalidRequestException`) keep their
15
+ botocore type: they are misconfiguration, not outages. Reads are idempotent; botocore's
16
+ standard retry mode makes up to 3 attempts. The client does not cache: every call is a
17
+ billed request, so callers that read the same secret often keep their own short-lived copy.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from dataclasses import dataclass
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ import boto3
27
+ from observability_021 import Observability
28
+ from observability_021.config import identifier
29
+
30
+ from clients_021 import _aws
31
+ from clients_021._observe import observed
32
+
33
+ if TYPE_CHECKING:
34
+ from mypy_boto3_secretsmanager import SecretsManagerClient as SecretsManagerSdk
35
+ else:
36
+ SecretsManagerSdk = Any
37
+
38
+ SYSTEM = {"rpc.system": "aws-api", "rpc.service": "SecretsManager"}
39
+
40
+
41
+ @dataclass(frozen=True, repr=False)
42
+ class Secret:
43
+ """One secret version. `value` is the SecretString or the SecretBinary; never printed."""
44
+
45
+ value: str | bytes
46
+ version_id: str
47
+
48
+ def text(self) -> str:
49
+ if not isinstance(self.value, str):
50
+ raise TypeError("this secret is binary; use binary()")
51
+ return self.value
52
+
53
+ def binary(self) -> bytes:
54
+ if not isinstance(self.value, bytes):
55
+ raise TypeError("this secret is a string; use text()")
56
+ return self.value
57
+
58
+ def __repr__(self) -> str:
59
+ return "Secret(<redacted>)"
60
+
61
+
62
+ class SecretsManagerClient:
63
+ def __init__(
64
+ self, obs: Observability, client: SecretsManagerSdk, *, dependency: str = "secrets"
65
+ ) -> None:
66
+ self._obs = obs
67
+ self._client = client
68
+ self.dependency = identifier(dependency)
69
+
70
+ @classmethod
71
+ def create(
72
+ cls,
73
+ obs: Observability,
74
+ *,
75
+ region: str | None = None,
76
+ endpoint_url: str | None = None,
77
+ dependency: str = "secrets",
78
+ timeout_seconds: float = 5.0,
79
+ ) -> SecretsManagerClient:
80
+ client: SecretsManagerSdk = boto3.client( # pyright: ignore[reportUnknownMemberType]
81
+ "secretsmanager",
82
+ region_name=region,
83
+ endpoint_url=endpoint_url,
84
+ config=_aws.config(
85
+ connect_seconds=min(timeout_seconds, 3.0), read_seconds=timeout_seconds
86
+ ),
87
+ )
88
+ return cls(obs, client, dependency=dependency)
89
+
90
+ async def get(
91
+ self,
92
+ secret_id: str,
93
+ *,
94
+ version_id: str | None = None,
95
+ version_stage: str | None = None,
96
+ ) -> Secret:
97
+ """The current version (`AWSCURRENT`) unless a version id or a stage is given."""
98
+ if not secret_id:
99
+ raise ValueError("secret_id is required")
100
+ if version_id is not None and version_stage is not None:
101
+ raise ValueError("pass version_id or version_stage, not both")
102
+ params: dict[str, str] = {"SecretId": secret_id}
103
+ if version_id is not None:
104
+ params["VersionId"] = version_id
105
+ if version_stage is not None:
106
+ params["VersionStage"] = version_stage
107
+ async with observed(
108
+ self._obs,
109
+ family="secrets",
110
+ system=SYSTEM,
111
+ dependency=self.dependency,
112
+ operation="get",
113
+ classify=_aws.classify,
114
+ ):
115
+ response = await asyncio.to_thread(self._client.get_secret_value, **params)
116
+ value: str | bytes
117
+ if "SecretString" in response:
118
+ value = response["SecretString"]
119
+ elif "SecretBinary" in response:
120
+ value = response["SecretBinary"]
121
+ else:
122
+ raise ValueError("the secret version has no value")
123
+ return Secret(value=value, version_id=response.get("VersionId", ""))
124
+
125
+ async def aclose(self) -> None:
126
+ """Release the underlying SDK connection pools after in-flight calls finish."""
127
+ await asyncio.to_thread(self._client.close)