021-python-clients 0.2.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.2.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
@@ -48,8 +48,8 @@ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras yo
48
48
 
49
49
  | Module | Extra | What it gives |
50
50
  | --- | --- | --- |
51
- | `clients_021.http` | none | `HttpClient`: one httpx client per dependency, timeouts, retries on idempotent methods |
52
- | `clients_021.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
51
+ | `clients_021.http.httpx` | none | `HttpClient`: buffered requests, streaming and full-body telemetry; native `ObservedTransport` |
52
+ | `clients_021.http.httpx2` | `httpx2` | `ObservedTransport` for SDKs built on httpx2 (Anthropic, OpenAI) |
53
53
  | `clients_021.cache.redis` | `redis` | `RedisClient`: JSON with TTL, one-time reads, windowed counters, ping |
54
54
  | `clients_021.queue.qstash` | none | `QStashClient`: JSON publishing, deduplication, delivery options and forwarded trace context |
55
55
  | `clients_021.queue.sqs` | `aws` | `SqsClient`: send, receive, delete, visibility, trace carried in the message |
@@ -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,15 +69,20 @@ 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 transports remain at the root. Import providers
70
- explicitly:
72
+ contains Redis; `crypto/` contains KMS; `secrets/` contains Secrets Manager; `llm/` contains Bedrock; `compute/` contains Kubernetes Jobs. `http/` contains the httpx client and httpx2
73
+ transport. Import providers explicitly:
71
74
 
72
75
  ```python
76
+ from clients_021.http import HttpClient
77
+ from clients_021.http.httpx2 import ObservedTransport # requires the httpx2 extra
73
78
  from clients_021.queue.sqs import SqsClient
74
79
  from clients_021.queue.qstash import QStashClient
75
80
  from clients_021.storage.s3 import S3Client
76
81
  from clients_021.cache.redis import RedisClient
77
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
78
86
  ```
79
87
 
80
88
  SQS supports polling, receipts and visibility changes; QStash delivers to an HTTP endpoint.
@@ -85,26 +93,35 @@ does not load the optional AWS SDK.
85
93
 
86
94
  The 0.1.0 imports `clients_021.redis`, `clients_021.s3` and `clients_021.kms` remain
87
95
  compatible aliases for the same classes and result types. Importing a capability package
88
- does not load its optional SDKs.
96
+ does not load its optional SDKs. `clients_021.HttpClient` and `clients_021.http` keep their
97
+ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
98
+ `clients_021.http.httpx2.ObservedTransport`.
89
99
 
90
100
  ## What every call gets
91
101
 
92
102
  - A CLIENT span named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
93
103
  system attribute (`db.system.name=redis`, `messaging.system=aws_sqs|resend|aws_ses`,
94
- `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`).
95
106
  - A counter and a duration histogram: `db.client.operation.*` (Redis, shared with SQL),
96
107
  `messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3, Vercel Blob),
97
- `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
98
112
  under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
99
- - Redis, SQS, QStash, S3, Vercel Blob, email and KMS emit a `dependency.call_failed` warning on failure,
100
- with dependency, operation and error type only. HTTP transports emit spans and metrics.
113
+ - Redis, SQS, QStash, S3, Vercel Blob, email, KMS, Secrets Manager, Bedrock and Kubernetes Jobs emit a `dependency.call_failed` warning on failure,
114
+ with dependency, operation and error type only. HTTP/httpx2 also log failed attempts,
115
+ including body failures and cancellation; QStash/Resend suppress the nested HTTP log.
101
116
  - `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
102
117
  refused the connection, throttled or failed with a 5xx. Map it to a 503. Errors that mean
103
118
  the request is wrong (access denied, a missing bucket, a wrong Redis type) keep their
104
119
  original type: they are bugs, not outages.
105
120
 
106
121
  Never in this library's spans, metrics or logs: keys, URLs, bodies, message ids or object
107
- 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,
108
125
  plaintext, ciphertext or data keys. Use static dependency names such as `identity-provider` or `uploads`; never derive
109
126
  them from a tenant, user or request. All clients apply observability's identifier rule
110
127
  (1–96 letters, digits, `_`, `.` or `-`, starting with a letter or digit).
@@ -267,6 +284,64 @@ except EmailRejected as rejected: # the address, the domain or the credentials;
267
284
  SES has no idempotency key (the argument is ignored): deduplicate in the caller when a retry
268
285
  must not send twice. Tags are for provider analytics; never put personal data in them.
269
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
+
270
345
  ### KMS
271
346
 
272
347
  The encryption context is required on every call, so a ciphertext only decrypts for the
@@ -290,21 +365,58 @@ ciphertext keep their botocore type (they are bugs or attacks, not outages); a t
290
365
  envelope raises `cryptography`'s `InvalidTag`. The envelope layout is in
291
366
  [CONTRACT.md](CONTRACT.md).
292
367
 
368
+ ### HTTP bodies and streaming
369
+
370
+ ```python
371
+ from clients_021.http import HttpClient
372
+
373
+ http = HttpClient(obs, timeout_seconds=10)
374
+ try:
375
+ async with http.stream("GET", download_url, dependency="documents") as response:
376
+ response.raise_for_status()
377
+ async for chunk in response.aiter_bytes():
378
+ await write_chunk(chunk)
379
+ finally:
380
+ await http.aclose()
381
+ ```
382
+
383
+ These async clients run on asyncio. `request()` returns a buffered `HttpResponse`; `stream()` yields a native httpx response
384
+ without buffering and accepts an async iterable as upload `content`. Streaming sends once,
385
+ even for GET. The buffered client's configured idempotent retries are unchanged. Close the
386
+ response context before closing the client. For SDKs using native httpx, inject
387
+ `clients_021.http.httpx.ObservedTransport`; httpx2 SDKs use the transport below.
388
+
389
+ Each HTTP attempt emits one span, counter and duration after body EOF and connection close,
390
+ or early close. This includes upload, download, pauses between chunks and cleanup. Body
391
+ errors after 200 headers are recorded as errors. `http.response.body.complete` distinguishes
392
+ EOF from partial/unread close; it does not mean the status or cleanup succeeded. Intentional
393
+ partial close is INFO `dependency.stream_closed`; exceptions, cancellation and HTTP >= 400
394
+ produce WARNING `dependency.call_failed`. No success log, URL, header, body or provider
395
+ error message is emitted. `log_failures=False` disables attempt logs when an enclosing
396
+ operation owns the warning, as QStash and Resend do.
397
+
398
+ These duration semantics and the completion label change the 0.2.x telemetry contract;
399
+ see [migration notes](CONTRACT.md#migration-from-02x) before updating dashboards or alerts.
400
+
293
401
  ### SDKs on httpx2
294
402
 
295
403
  For an SDK on httpx2:
296
404
 
297
405
  ```python
298
406
  from anthropic import AsyncAnthropic, DefaultAsyncHttpxClient
299
- from clients_021.httpx2 import ObservedTransport
407
+ from clients_021.http.httpx2 import ObservedTransport
300
408
 
301
409
  transport = ObservedTransport(obs, dependency="anthropic")
302
410
  claude = AsyncAnthropic(http_client=DefaultAsyncHttpxClient(transport=transport))
303
411
  ```
304
412
 
305
- It does not send `traceparent` to third-party APIs unless `propagate=True`.
413
+ It does not inject trace context into third-party API requests unless `propagate=True`.
306
414
  The transport preserves SDK exceptions so the SDK retains control of retries and error
307
- handling; it does not convert them to `DependencyUnavailable`.
415
+ handling; it does not convert them to `DependencyUnavailable`. Its span stays open through
416
+ incremental response reads and close. Use the SDK's stream context manager, or close manual
417
+ responses explicitly; abandoning a stream leaves both its connection and span open. Caller
418
+ processing keeps its own trace context, including when streams are interleaved or consumed
419
+ in another task.
308
420
 
309
421
  ## Service integration
310
422
 
@@ -333,18 +445,20 @@ async with AsyncExitStack() as resources:
333
445
  resources.push_async_callback(email.aclose)
334
446
  keys = KmsClient.create(obs, settings.session_key_arn, region="us-east-1")
335
447
  resources.push_async_callback(keys.aclose)
448
+ secrets = SecretsManagerClient.create(obs, region="us-east-1")
449
+ resources.push_async_callback(secrets.aclose)
336
450
  # Run the service here. Stop its background workers before leaving this scope.
337
451
  ```
338
452
 
339
453
  Retry policies are explicit:
340
454
 
341
- - HTTP retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
455
+ - Buffered HTTP (`request`) retries GET, HEAD, OPTIONS, PUT and DELETE on network failures, timeouts,
342
456
  remote protocol/proxy connection failures and 502/503/504. `retries=2` means at most
343
457
  three attempts.
344
458
  POST and PATCH run once. Invalid URLs/protocols and invalid request construction keep
345
459
  their original exception. HTTP status responses are returned for the caller to handle.
346
460
  - Resend sends each request once (POST); send an `idempotency_key` and retry in the caller.
347
- - 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
348
462
  attempts**.
349
463
  Injected SDK clients keep their configured policy. SQS consumers must tolerate duplicate
350
464
  messages; a timeout does not prove that a send or write was not applied.
@@ -367,15 +481,16 @@ any operation**. Map it at the service boundary and apply the operation's idempo
367
481
  Its message and structured fields are safe to log when dependency names are static. Its
368
482
  chained SDK exception may contain sensitive provider data; do not log exception chains.
369
483
 
370
- 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
371
485
  stop an SDK request already running in its thread. Timeouts are per SDK/network attempt,
372
486
  not an overall deadline. Limit consumer concurrency and let in-flight work finish before
373
487
  closing the clients. Redis window counters require Redis server 7 or newer (`EXPIRE NX`).
374
488
 
375
- HTTP transport spans end when response headers arrive. Body downloads and SDK streaming
376
- are outside that span; instrument the enclosing service/SDK operation when you need total
377
- latency or failures while consuming the body. S3 `get` buffers the entire object in memory;
378
- use a dedicated streaming adapter for large files.
489
+ HTTP spans now include network body consumption and cleanup. The SDK/application still
490
+ owns content decoding, JSON/SSE interpretation and processing errors outside raw-stream I/O.
491
+ Timeouts remain per I/O, not a total stream deadline. Cancellation waits for shielded body
492
+ cleanup before propagating. Close all responses and consumers before shutdown/flush.
493
+ S3 `get` buffers the entire object in memory; use a dedicated streaming adapter for large files.
379
494
 
380
495
  ## Development
381
496
 
@@ -390,6 +505,8 @@ deduplication, start the [official local server](https://upstash.com/docs/qstash
390
505
  set its `QSTASH_URL` and `QSTASH_TOKEN`, and run `uv run python tests/qstash_local.py`.
391
506
  That smoke test accepts only a loopback QStash server and binds its destination dynamically.
392
507
 
393
- 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
394
511
  botocore's Stubber where moto ignores a field or differs from AWS); Resend tests use httpx's
395
512
  `MockTransport`; Redis tests need a real Redis. Licensed under Apache-2.0.
@@ -1,28 +1,37 @@
1
- clients_021/__init__.py,sha256=sF_9ZsxPumQbZ-9iCXoLP8JcU_tITDT6Xk1mXF9PVuQ,816
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
- clients_021/http.py,sha256=-AsGLfngd-eEJnTuZEPdVBI9myYiuggmnrvjQjaMpT8,5045
6
- clients_021/httpx2.py,sha256=w248IcEtDZtsEDkkvj4Bpfl7MU7IJ08EHqHWhryC_3k,3880
5
+ clients_021/httpx2.py,sha256=rNbNGJuPJPZUwVbNN5L88IF0g3Tq6EHqj_uFmJ3RmLQ,161
7
6
  clients_021/kms.py,sha256=YcZXURpqIZTkjcP0fpWMD6X_adMAYwN7zAJNhUImR4s,183
8
7
  clients_021/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
8
  clients_021/redis.py,sha256=xC3tji_ldJAwaBntkZTuPhNHLIzl3F3nDzkikMrbJCc,169
10
9
  clients_021/s3.py,sha256=6Q5_1emngSB00IwvBdrmV3iAg-Q_Ndco1HGzYObL5FI,231
11
10
  clients_021/cache/__init__.py,sha256=3OyQQ-uZeghpgJvvJ2k56_t50q3CCwbAp3dBrCMMcSU,76
12
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
13
14
  clients_021/crypto/__init__.py,sha256=qESFBPQBKcHhdFXqeJzSyEP41yveG7rFrxnpITmqlNU,77
14
15
  clients_021/crypto/kms.py,sha256=My5Xyg0bOiT9YHYWojQSszpREEh3bSYMMB26wy6fNEI,7261
15
16
  clients_021/email/__init__.py,sha256=ivmdu9hhNDwPqBn3G7uNG0Zpc3DEMnlnWBVVoVQ-fQA,790
16
17
  clients_021/email/_message.py,sha256=ulTX2cXPxSVaq_5FVF0e6glePVJ7wiO5P3M4WUWN3N0,3258
17
- clients_021/email/resend.py,sha256=ugENkEzw9eUlYih3BZcCVQswTF1MDikPgZyuqdapCjc,5347
18
+ clients_021/email/resend.py,sha256=1EjvEbNbbVDQs5TZfYuZrqh14io8cb5ADneNA63DQbg,5416
18
19
  clients_021/email/ses.py,sha256=0ukHvuVE0nTU5mYWRy2EaHY5v9dUwi4GSKES93pjqBg,4668
20
+ clients_021/http/__init__.py,sha256=P4OmYruhkQfJwJzHA90FXKWg_A2rh5iOl-ES_NTMMVQ,311
21
+ clients_021/http/_observe.py,sha256=hpTzcej9MhlvM-RgUZWicR57ulE90GmZMs5U_qFoquQ,6922
22
+ clients_021/http/httpx.py,sha256=gvqSpO1rb6nBA5i01W1yos5mPBwLqa3-HDYwMtQXc6s,10627
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
19
26
  clients_021/queue/__init__.py,sha256=CesweOrXmX5cyU-mRvqu-4wLkNJI15zi_Lows6SPupY,338
20
- clients_021/queue/qstash.py,sha256=j3vz_S9-ed0YQZCaWwQg5wPwf8Bv5P1voNsx8WD8Zhc,8315
27
+ clients_021/queue/qstash.py,sha256=NndlqmNkcBgs0ExGsT-3-bMAdU0skO8Djptqj8NoJAM,8335
21
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
22
31
  clients_021/storage/__init__.py,sha256=KrJViRHBK7PkIYnagg7vyXp95SzMFp9cUCKEnMeFr84,78
23
32
  clients_021/storage/s3.py,sha256=NDtG9QzHSXvrcazTYTq1EH5VjuRd2xMMsMvaumqci4Q,5946
24
33
  clients_021/storage/vercel_blob.py,sha256=FlnoKiahqtk3PpLsGpyu_Gy87otg15i4BlJZsIB3LfM,6511
25
- 021_python_clients-0.2.0.dist-info/METADATA,sha256=gx7Jt2wjPNVFhyUHCze-tQ5YVu-Kqrt2GwAyia6HdXs,20595
26
- 021_python_clients-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
- 021_python_clients-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
28
- 021_python_clients-0.2.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,9 +1,11 @@
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.httpx2` the `httpx2` 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;
8
+ `clients_021.http.httpx2` the `httpx2` extra.
7
9
  `clients_021.storage.vercel_blob` needs the `vercel-blob` extra.
8
10
  """
9
11
 
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."""