encore-dev 2.0.0a7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. encore_dev-2.0.0a7/PKG-INFO +791 -0
  2. encore_dev-2.0.0a7/README.md +778 -0
  3. encore_dev-2.0.0a7/encore/__init__.py +43 -0
  4. encore_dev-2.0.0a7/encore/cron/__init__.py +50 -0
  5. encore_dev-2.0.0a7/encore/internal/__init__.py +1 -0
  6. encore_dev-2.0.0a7/encore/internal/_loader.py +79 -0
  7. encore_dev-2.0.0a7/encore/internal/_native.pyi +734 -0
  8. encore_dev-2.0.0a7/encore/internal/lazy.py +35 -0
  9. encore_dev-2.0.0a7/encore/internal/runtime.py +133 -0
  10. encore_dev-2.0.0a7/encore/internal/startup.py +24 -0
  11. encore_dev-2.0.0a7/encore/logs/__init__.py +19 -0
  12. encore_dev-2.0.0a7/encore/logs/_console.py +155 -0
  13. encore_dev-2.0.0a7/encore/logs/_runtime.py +56 -0
  14. encore_dev-2.0.0a7/encore/meta/__init__.py +228 -0
  15. encore_dev-2.0.0a7/encore/metrics/__init__.py +390 -0
  16. encore_dev-2.0.0a7/encore/pubsub/__init__.py +425 -0
  17. encore_dev-2.0.0a7/encore/py.typed +0 -0
  18. encore_dev-2.0.0a7/encore/secrets/__init__.py +139 -0
  19. encore_dev-2.0.0a7/encore/shutdown/__init__.py +164 -0
  20. encore_dev-2.0.0a7/encore/storage/__init__.py +5 -0
  21. encore_dev-2.0.0a7/encore/storage/objects/__init__.py +396 -0
  22. encore_dev-2.0.0a7/encore/storage/postgres/__init__.py +737 -0
  23. encore_dev-2.0.0a7/encore/storage/redis/__init__.py +200 -0
  24. encore_dev-2.0.0a7/encore/storage/redis/_base.py +42 -0
  25. encore_dev-2.0.0a7/encore/storage/redis/_responses.py +150 -0
  26. encore_dev-2.0.0a7/encore/storage/redis/_values.py +189 -0
  27. encore_dev-2.0.0a7/encore/storage/redis/bitmap.py +142 -0
  28. encore_dev-2.0.0a7/encore/storage/redis/generic.py +367 -0
  29. encore_dev-2.0.0a7/encore/storage/redis/geo.py +508 -0
  30. encore_dev-2.0.0a7/encore/storage/redis/hash.py +504 -0
  31. encore_dev-2.0.0a7/encore/storage/redis/json.py +614 -0
  32. encore_dev-2.0.0a7/encore/storage/redis/list.py +511 -0
  33. encore_dev-2.0.0a7/encore/storage/redis/pubsub.py +276 -0
  34. encore_dev-2.0.0a7/encore/storage/redis/set.py +570 -0
  35. encore_dev-2.0.0a7/encore/storage/redis/sorted_set.py +753 -0
  36. encore_dev-2.0.0a7/encore/storage/redis/stream.py +773 -0
  37. encore_dev-2.0.0a7/encore/storage/redis/string.py +654 -0
  38. encore_dev-2.0.0a7/encore/tracing/__init__.py +19 -0
  39. encore_dev-2.0.0a7/encore/tracing/_context.py +27 -0
  40. encore_dev-2.0.0a7/encore/tracing/_encode.py +351 -0
  41. encore_dev-2.0.0a7/encore/tracing/_provider.py +62 -0
  42. encore_dev-2.0.0a7/encore/tracing/_span.py +226 -0
  43. encore_dev-2.0.0a7/encore/tracing/_tracer.py +93 -0
  44. encore_dev-2.0.0a7/pyproject.toml +72 -0
  45. encore_dev-2.0.0a7/pyproject.toml.orig +60 -0
@@ -0,0 +1,791 @@
1
+ Metadata-Version: 2.3
2
+ Name: encore-dev
3
+ Version: 2.0.0a7
4
+ Summary: Encore SDK for Python — infrastructure resources
5
+ Author: Encore
6
+ License: MPL-2.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Dist: opentelemetry-api>=1.27
9
+ Requires-Dist: opentelemetry-semantic-conventions>=0.62b1
10
+ Requires-Dist: typing-extensions>=4.12
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Encore SDK for Python
15
+
16
+ The Python SDK for [Encore](https://encore.dev) — **infrastructure resources only**.
17
+
18
+ The distribution is named `encore-dev`; Python code imports it as `encore`.
19
+
20
+ Declare infrastructure (SQL databases, pub/sub topics & subscriptions, object
21
+ storage buckets, Redis databases, cron jobs, metrics, secrets) as module-level variables.
22
+ Encore's parser discovers them for provisioning and wiring, and the native runtime
23
+ binding (`encore_sdk_core` over PyO3) connects them to the provisioned
24
+ infrastructure at run time.
25
+
26
+ Services are declared by the language-agnostic `encore.service.json` file, not in
27
+ Python — this SDK is infrastructure-only (the counterpart of the Go/TS/Rust SDKs).
28
+
29
+ ```python
30
+ from encore import pubsub, secrets
31
+ from encore.storage import postgres
32
+
33
+ db = postgres.Database("orders", migrations="./migrations")
34
+ events = pubsub.Topic("order-events", exactly_once=True)
35
+ api_key = secrets.Secret[str]("ApiKey")
36
+
37
+
38
+ async def place_order(order):
39
+ await db.exec("INSERT INTO orders (id) VALUES ($1)", order.id)
40
+ await events.publish({"id": order.id})
41
+ ```
42
+
43
+ ## Postgres queries and handles
44
+
45
+ Declare `postgres.Database("orders", migrations="./migrations")` at module scope.
46
+ `migrations` is optional and keyword-only. Use `Database.named("orders")` to
47
+ reference a database declared elsewhere.
48
+
49
+ `query(sql, *params)` returns a `Cursor`. Use `async for` to stream rows,
50
+ `await query.one()` to fetch one row or `None` (rejecting multiple rows), or
51
+ `await query.all()` to collect all rows in a list. Each query can be consumed
52
+ once. Reusing it or mixing helpers with iteration raises `EncoreError`, including
53
+ after partial iteration or an error. Helpers close their cursors automatically.
54
+ For an early exit from streaming, use `async with db.query(...) as query:`.
55
+ Entering the context does not execute SQL. Exiting it closes the query.
56
+
57
+ `exec(sql, *params)` returns an `ExecResult` with `rows_affected`. Rows are
58
+ dictionaries keyed by column name. Values are bound with `$1`, `$2`, etc.
59
+
60
+ ```python
61
+ async def find_order(database: postgres.Queryable, order_id: int):
62
+ return await database.query("SELECT * FROM orders WHERE id = $1", order_id).one()
63
+
64
+
65
+ async def handle_order(order_id: int):
66
+ return await find_order(db.read(), order_id)
67
+ ```
68
+
69
+ `DatabaseRef`, `Connection`, and `Transaction` implement the nominal `Queryable`
70
+ interface. `Database` exposes the same query methods but deliberately does not
71
+ implement `Queryable`. Encore rejects passing or aliasing the declaration;
72
+ select `read()`, `write()`, or `admin()` before handing it to another function.
73
+ Direct database operations infer writer permissions. Reference creation records
74
+ reader, writer, or admin permissions at that call site.
75
+
76
+ Like the Rust SDK, access selection currently controls parser permission
77
+ inference. Handles still share one runtime pool; per-handle privilege enforcement
78
+ and replica routing (including the `read(primary=True)` preference) are pending.
79
+
80
+ ```python
81
+ async def rename_order(order_id: int, name: str):
82
+ async with db.acquire() as conn:
83
+ async with conn.begin() as tx:
84
+ result = await tx.exec(
85
+ "UPDATE orders SET name = $1 WHERE id = $2", name, order_id
86
+ )
87
+ return result.rows_affected
88
+ ```
89
+
90
+ Both `begin()` and `acquire()` can be awaited or used as async context managers.
91
+ Transaction contexts commit on a clean exit and roll back on exceptions;
92
+ connection contexts return the connection to its pool. A connection cannot be
93
+ used or closed while its transaction is active. Closed handles reject queries.
94
+
95
+ Streaming queries borrow their connection or transaction until exhausted or
96
+ closed. If you stop early, close the stream explicitly before reusing its owner:
97
+
98
+ ```python
99
+ async def first_order():
100
+ async with db.acquire() as conn:
101
+ async with conn.query("SELECT * FROM orders ORDER BY id") as rows:
102
+ async for row in rows:
103
+ return row
104
+ ```
105
+
106
+ Native Postgres integration tests can be enabled with
107
+ `ENCORE_PYTHON_POSTGRES_TEST_HOST=host:port`. They expect an isolated Postgres
108
+ instance with database/user `postgres` and password `encore-test`, and create and
109
+ remove a temporary schema.
110
+
111
+ ## Pub/Sub payloads
112
+
113
+ Use `pubsub.Subscription[T]` to choose how payloads are decoded. Encore validates
114
+ the type argument when subscriptions start. Omitting it selects ordinary JSON
115
+ decoding. Handler annotations guide type checking but do not select the decoder.
116
+
117
+ ```python
118
+ from pydantic import BaseModel
119
+
120
+
121
+ class Order(BaseModel):
122
+ id: str
123
+
124
+
125
+ async def process_order(message: pubsub.Message[Order]) -> None:
126
+ print(message.data.id)
127
+
128
+
129
+ processed = pubsub.Subscription[Order](events, "process-order", handler=process_order)
130
+ ```
131
+
132
+ Supported payloads are JSON values, bytes, and top-level Pydantic v2 models
133
+ (when Pydantic is installed). `pubsub.JSON` and `pubsub.Payload` describe these
134
+ public types. Use `Subscription[pubsub.JSON]` for plain JSON: decoding returns ordinary
135
+ Python values without validating or constructing nested types. Use a Pydantic
136
+ model when fields need validation or nested models need construction.
137
+
138
+ `Subscription[bytes]` returns the payload unchanged. Unparameterized `Subscription`
139
+ decodes ordinary JSON. Dataclasses, model unions, models nested in plain JSON
140
+ containers, and string forward references are not supported; wrap nested models
141
+ in a top-level Pydantic model and pass that class directly. Original bytes remain
142
+ available as `message.raw_data`. Payloads are decoded once before the handler is
143
+ called; decoding errors propagate for normal retry handling.
144
+
145
+ ## Application-owned shutdown
146
+
147
+ Without a hook, Encore shuts down non-cooperatively as the interpreter exits:
148
+ after the server has drained, it drains Pub/Sub and flushes telemetry. On
149
+ import it makes SIGTERM exit the interpreter, unless a handler is already set
150
+ or the process runs tests: with 0 once the shutdown completed in time, else 1.
151
+
152
+ Use cooperative shutdown when application tasks may still use Encore resources.
153
+ The following hook accepts a framework-specific callback that stops new
154
+ application work and waits for existing work to finish:
155
+
156
+ ```python
157
+ from typing import Awaitable, Callable
158
+
159
+ from encore import shutdown
160
+
161
+
162
+ async def on_shutdown(
163
+ stop_and_drain_application: Callable[[], Awaitable[None]],
164
+ ) -> int:
165
+ shutdown.begin(cooperative=True)
166
+
167
+ await stop_and_drain_application()
168
+ shutdown.mark_application_drained()
169
+
170
+ result = await shutdown.completed()
171
+ return result.code()
172
+ ```
173
+
174
+ Connect this hook to the host's shutdown lifecycle. Keep its event loop and the
175
+ Encore runtime alive until cleanup completes. The host should enforce its own
176
+ overall shutdown deadline and decide when to terminate the process.
177
+
178
+ Call `begin()` before awaiting phase helpers. With cooperative shutdown, signal
179
+ application draining before awaiting `cleanup()` or `completed()`; otherwise
180
+ Encore waits for that signal until its deadline expires.
181
+
182
+ If application work has already drained, use non-cooperative shutdown:
183
+
184
+ ```python
185
+ from encore import shutdown
186
+
187
+
188
+ async def stop_encore() -> int:
189
+ shutdown.begin() # cooperative=False
190
+ result = await shutdown.completed()
191
+ return result.code()
192
+ ```
193
+
194
+ `begin()` returns immediately. All callers share the same shutdown process, and
195
+ the first call chooses the cooperative mode. Repeated calls do not restart
196
+ cleanup, change the mode, or reset the deadline. Cancelling an individual waiter
197
+ does not cancel background cleanup.
198
+
199
+ ## Phases and observation
200
+
201
+ | Phase | Meaning |
202
+ | --- | --- |
203
+ | `NOT_STARTED` | Shutdown has not started. |
204
+ | `STARTED` | Pub/Sub intake has been signalled to stop. Encore is waiting for message handlers and, in cooperative mode, application draining. |
205
+ | `CLEANUP` | Encore has stopped waiting for draining and is performing resource cleanup. |
206
+ | `COMPLETED` | The cleanup task has finished, including if it failed. Check the result or exception. |
207
+
208
+ `phase()` reads the current phase synchronously. `await started()` and
209
+ `await cleanup()` wait until shutdown has reached **or passed** their respective
210
+ phases. They return immediately if the phase has already passed.
211
+
212
+ `await completed()` waits for actual cleanup completion, including after the
213
+ deadline. These waits are independent: callers do not need to await every phase
214
+ in sequence. Reading `phase()` is valid before `begin()`; waiting for a phase
215
+ before `begin()` raises `ShutdownError`.
216
+
217
+ `CLEANUP` closes SQL, Redis, and object-storage resources concurrently, then
218
+ exports final metrics and flushes tracing. Retaining a database, cache client,
219
+ bucket, or object handle does not keep its connections usable after cleanup.
220
+ New operations fail; interrupted object downloads report an error rather than
221
+ returning a partial body as success, and Redis subscriptions end.
222
+
223
+ Drain application work before signalling `mark_application_drained()`. Forced
224
+ cleanup cancels Encore-owned resource I/O even if application tasks are still
225
+ running. External clients created from connection URLs are owned by your
226
+ application. Cloud SDKs release their HTTP pools when their clients are dropped;
227
+ best-effort S3 multipart abort requests may outlive `completed()` and require a
228
+ running executor to finish.
229
+
230
+ ## Deadline and forced cleanup
231
+
232
+ The core deadline defaults to five seconds, configured by
233
+ `deployment.graceful_shutdown.total` in the runtime configuration. It starts
234
+ when shutdown is first begun and covers both draining and cleanup.
235
+
236
+ When the deadline expires:
237
+
238
+ 1. Encore records `Timeout` in the shared state.
239
+ 2. If draining is still in progress, Encore stops waiting for it and enters
240
+ `CLEANUP`, even without an application-drained signal.
241
+ 3. Cleanup continues. `completed()` returns only after the cleanup task finishes.
242
+
243
+ The timeout is sticky: finishing later does not change it to success. It is not
244
+ a hard upper bound on how long `completed()` can wait. The controller does not
245
+ exit the process when its deadline expires.
246
+
247
+ To skip draining before the deadline, request forced cleanup:
248
+
249
+ ```python
250
+ from encore import shutdown
251
+
252
+
253
+ async def force_remaining_cleanup() -> int:
254
+ # Shutdown must already have been started with begin().
255
+ shutdown.force_cleanup()
256
+ await shutdown.cleanup() # Observe the transition; cleanup is not done yet.
257
+ result = await shutdown.completed()
258
+ return result.code()
259
+ ```
260
+
261
+ `force_cleanup()` returns immediately and is idempotent. It raises
262
+ `ShutdownError` before `begin()`. Once resource cleanup has started, forcing it
263
+ again has no effect. Forcing alone does not record `Timeout`: successful cleanup
264
+ before the deadline still returns `SUCCESS`.
265
+
266
+ Both the deadline and explicit forcing abandon the drain wait; they do not kill
267
+ active handlers. Those handlers may continue using resources while cleanup runs.
268
+ The host must account for this when choosing when to force cleanup.
269
+
270
+ ## Results and errors
271
+
272
+ | Python outcome | Meaning |
273
+ | --- | --- |
274
+ | `ShutdownResult.SUCCESS` | Cleanup finished before the deadline. Explicit forcing may have skipped draining. `code()` returns `0`. |
275
+ | `ShutdownResult.TIMEOUT` | The deadline expired before cleanup finished. The returned result still means the cleanup task has now finished. `code()` returns `1`. |
276
+ | `ShutdownError` | The core reported cleanup failure or progress could not be observed, including waiting before `begin()`. |
277
+
278
+ The SDK maps native `Failed` to `ShutdownError`; it does not return the public
279
+ `FAILED` enum member from `completed()`. Native progress errors are translated
280
+ to `ShutdownError` with the original `EncoreError` retained as their cause.
281
+ An unrecognized native enum value raises `ValueError`.
282
+
283
+ If cleanup panics after the deadline has already recorded `Timeout`, the core
284
+ logs the failure and preserves `Timeout` as the reported result. A timeout is
285
+ not evidence that all cleanup operations eventually succeeded.
286
+
287
+ ## Packaging
288
+
289
+ `encore-dev` contains the Python SDK and is built with uv:
290
+
291
+ ```sh
292
+ uv build sdk/py/infra
293
+ ```
294
+
295
+ For release packaging, stage the SDK and stamp its version without changing the
296
+ checked-in manifest:
297
+
298
+ ```sh
299
+ cargo xtask pack-python --version v2.0.0-alpha.1
300
+ ```
301
+
302
+ This writes `encore_dev-2.0.0a1-py3-none-any.whl`, the source distribution, and
303
+ SHA-256 sidecars to `dist/python/` (override with `--output`). Alpha and beta
304
+ versions become `aN` and `bN`; nightly and development versions become `.devN`.
305
+ The command requires uv and the native build prerequisites (including Protoc).
306
+ It builds the native runtime to generate fresh type stubs, but packages only the
307
+ pure Python SDK and does not publish to PyPI.
308
+
309
+ The Rust extension lives in the parent `sdk/py/` directory and is packaged separately by Maturin.
310
+ Its `encore-runtime` wheel is an internal artifact for the CLI, not a dependency
311
+ of the public SDK:
312
+
313
+ ```sh
314
+ # With Maturin installed:
315
+ cargo xtask build-py-runtime --release --wheel
316
+ # Optionally select the wheel's target (requires a suitable toolchain):
317
+ cargo xtask build-py-runtime --release --wheel --target aarch64-apple-darwin
318
+ ```
319
+
320
+ SDK distributions are written to `sdk/py/infra/dist/`; native wheels go to
321
+ `sdk/py/dist/`.
322
+
323
+ ## Building locally
324
+
325
+ From the repository root:
326
+
327
+ ```sh
328
+ cargo xtask build-py-runtime
329
+ ```
330
+
331
+ This stages `_encore_runtime` inside `infra/encore/` for the SDK's local loader,
332
+ and in `runtimes/python/` for the development CLI. It also regenerates
333
+ `encore/internal/_native.pyi` from the compiled extension. Edit native signatures
334
+ in Rust, then rerun this command to update the stub. An editable install of the SDK
335
+ can then load the native extension without setting `PYTHONPATH`:
336
+
337
+ ```sh
338
+ uv run --project sdk/py/infra pytest sdk/py/infra/tests
339
+ ```
340
+
341
+ The CLI sets `ENCORE_PYTHON_RUNTIME_DIR` for runs, tests, and exec commands.
342
+ The SDK selects the native library using the running interpreter's build ABI:
343
+ standard CPython uses the root `abi3` library; free-threaded Python 3.14 uses
344
+ `cp314t/_encore_runtime.so` (`.pyd` on Windows). This selection is unchanged if
345
+ a free-threaded interpreter temporarily enables its GIL. Unsupported ABIs fail
346
+ with a missing-runtime error instead of falling back to an incompatible library.
347
+
348
+ Build the local free-threaded variant separately:
349
+
350
+ ```sh
351
+ cargo xtask build-py-runtime --python-runtime cp314t
352
+ ```
353
+
354
+ The default builds `abi3`. Both variants can coexist. Native-only wheel builds
355
+ (`--wheel`) continue to use maturin's interpreter selection instead.
356
+
357
+ `ENCORE_PYTHON_RUNTIME_LIB` overrides directory selection with an explicit library.
358
+ An explicit library or directory is authoritative; failures do not fall back.
359
+ Without either variable, the loader searches beside itself and in parent
360
+ and `dist/` directories using the same ABI layout.
361
+
362
+ Configure the development CLI's `ENCORE_RUNTIMES_PATH` to point at this checkout's
363
+ `runtimes/` directory. Published SDK wheels exclude native libraries; standalone
364
+ use requires a runtime directory or explicit library from the CLI installation.
365
+
366
+ App builds install `encore-dev` and copy the runtime directory, preserving its ABI
367
+ variants for selection by the deployment interpreter. An explicit library override
368
+ is packaged separately and remains authoritative. Cross-platform runtime retrieval
369
+ is not yet supported.
370
+
371
+ ## Resource initialization
372
+
373
+ Importing the SDK synchronously initializes the native Encore runtime, matching
374
+ TypeScript. Run applications through Encore so runtime configuration is available
375
+ before imports. Initialization failures surface during import, before normal
376
+ application startup completes.
377
+
378
+ Buckets, SQL databases, pub/sub topics, and secrets store their declarations and
379
+ resolve native handles on first use, caching the results with thread-safe
380
+ initialization. These lookups are synchronous against the already initialized
381
+ runtime. Importing application modules requires the configured runtime and native
382
+ extension. SDK unit tests supply a minimal runtime configuration
383
+ in `tests/conftest.py`; integration tests use `encore test`.
384
+
385
+ Initialize independently in each worker process. Forking an already initialized
386
+ native runtime is not supported by this startup model.
387
+
388
+ ## Linting and formatting
389
+
390
+ From the repository root, lint the SDK and its tests:
391
+
392
+ ```sh
393
+ uv run --locked --project sdk/py/infra ruff check sdk/py/infra
394
+ ```
395
+
396
+ Add `--fix` to apply safe fixes. The enabled rules check syntax/style errors,
397
+ undefined and unused names, common bug patterns, and Google-style docstrings
398
+ (`E4`, `E7`, `E9`, `F`, `B`, `D`).
399
+
400
+ From the repository root, format the SDK and its tests with Ruff:
401
+
402
+ ```sh
403
+ uv run --project sdk/py/infra ruff format sdk/py/infra
404
+ ```
405
+
406
+ To check formatting without changing files:
407
+
408
+ ```sh
409
+ uv run --project sdk/py/infra ruff format --check sdk/py/infra
410
+ ```
411
+
412
+ The generated `_native.pyi` is excluded from linting and formatting because it
413
+ is owned by the PyO3 stub generator. It is still checked by ty.
414
+
415
+ ## Numeric counters and label groups
416
+
417
+ Counters default to unsigned 64-bit integers. Use `Counter[float]` for fractional
418
+ increments and `CounterGroup[Labels, float]` for labeled float counters:
419
+
420
+ ```python
421
+ from dataclasses import dataclass
422
+
423
+ from encore import metrics
424
+
425
+
426
+ @dataclass(frozen=True)
427
+ class Labels:
428
+ operation: str
429
+
430
+
431
+ requests = metrics.CounterGroup[Labels]("requests")
432
+ usage = metrics.CounterGroup[Labels, float]("usage")
433
+ cpu_time = metrics.Counter[float]("cpu_time")
434
+
435
+ requests.with_labels(Labels("checkout")).increment()
436
+ usage.with_labels(Labels("checkout")).increment(0.125)
437
+ cpu_time.increment(0.025)
438
+ ```
439
+
440
+ This syntax works on Python 3.9 and later. Put numeric type arguments on the
441
+ constructor; a variable annotation alone does not select runtime storage.
442
+ `with_labels()` returns `Counter[int]` or `Counter[float]` to the type checker.
443
+ Existing `Counter(name)` and `CounterGroup[Labels](name)` keep their integer
444
+ behavior. Gauge values already use floating-point storage.
445
+
446
+ Float increments must be finite and nonnegative. An update that would overflow
447
+ the total raises `ValueError` without changing it. Double-precision totals can
448
+ incur rounding, including loss of integer precision above `2**53`. Integer and
449
+ float counters cannot share a native series. Integer totals still wrap on 64-bit
450
+ overflow. Calling `increment()` without a value adds one in either mode.
451
+
452
+ All metric groups accept dataclass instances as well as mappings, including
453
+ TypedDicts. Fields must contain strings, booleans, or finite numbers. Nested
454
+ objects are rejected. Numeric labels are rounded down to integer strings and
455
+ booleans become `"true"` or `"false"`. Names are normalized to snake_case; `service`
456
+ is reserved. The label values are captured when binding, so later mutation does
457
+ not change an existing series. `GaugeGroup[Labels]` and `HistogramGroup[Labels]`
458
+ also provide typed label arguments.
459
+
460
+ ## Histogram metrics
461
+
462
+ Declare histograms at module scope to record distributions such as request
463
+ duration or payload size. Record one observation for each measurement:
464
+
465
+ ```python
466
+ from typing import TypedDict
467
+
468
+ from encore import metrics
469
+
470
+
471
+ class Labels(TypedDict):
472
+ route: str
473
+
474
+
475
+ latency = metrics.Histogram("request_duration", unit="seconds")
476
+ sizes = metrics.HistogramGroup[Labels]("response_size", unit="bytes")
477
+
478
+ latency.observe(0.025)
479
+ sizes.with_labels({"route": "/orders"}).observe(512)
480
+ ```
481
+
482
+ The runtime maintains count, sum, and automatic exponential buckets without
483
+ keeping individual measurements. Bucket widths grow by at most 10%. `unit` is
484
+ optional metadata, must be a string literal when supplied, and does not convert
485
+ values. Histogram groups normalize and cache labels like counter groups. Use
486
+ labels with a small set of values: each combination retains a series for the
487
+ process lifetime.
488
+
489
+ Prefer finite observations. As in Rust, NaN increments the count and makes the
490
+ sum NaN without entering a bucket; infinities enter their own buckets.
491
+
492
+ Prometheus remote write exports the bucket distribution for approximate
493
+ percentile queries. AWS, GCP, and Datadog currently export only `<name>_count`
494
+ and `<name>_sum`, which support averages but not percentiles.
495
+
496
+ ## Documentation style
497
+
498
+ Follow the [Google Python docstring guide](https://google.github.io/styleguide/pyguide.html#docstrings)
499
+ for modules, classes, functions, methods, and Python examples in documentation.
500
+ Start docstrings with a concise summary on the opening triple-double-quote line.
501
+ End the summary with punctuation, keep it within 80 characters, and separate any
502
+ further explanation with a blank line. Use imperative summaries for functions
503
+ ("Return the current phase.") and noun phrases for classes and properties.
504
+
505
+ Use `Args:`, `Returns:`, `Yields:`, `Raises:`, and `Attributes:` as appropriate,
506
+ with four-space indentation for entries and continuation lines. Document behavior
507
+ callers need to know: units, defaults, side effects, resource ownership, and
508
+ relevant exceptions. Parameter names must match the signature, including `*args`
509
+ and `**kwargs`; type annotations usually make repeated type descriptions
510
+ unnecessary. Document public data attributes in the class's `Attributes:` section
511
+ and constructor arguments in `__init__`.
512
+
513
+ Simple APIs can use a one-line docstring when the summary and signature explain
514
+ their behavior completely. Omit `Returns:` for functions returning only `None`;
515
+ use `Yields:` for generators, describing each yielded item. For a decorated
516
+ context manager, describe the context manager returned to the caller. Put module
517
+ examples under `Typical usage example:`. Keep README prose and examples consistent
518
+ with these API contracts; Markdown guides retain their normal heading structure.
519
+
520
+ Ruff enforces the Google docstring convention through the existing lint command
521
+ and pre-commit hook. Test docstrings are optional; add them to explain unusual
522
+ setup or behavior rather than repeating test names. Existing test docstrings
523
+ still follow the same formatting rules. Review documentation for accuracy as
524
+ well as formatting: Ruff cannot verify behavioral contracts.
525
+
526
+ ## Type checking
527
+
528
+ From the repository root, check the SDK, native stubs, and tests with ty:
529
+
530
+ ```sh
531
+ uv run --project sdk/py/infra ty check --project sdk/py/infra
532
+ ```
533
+
534
+ The checker uses the minimum Python version from `requires-python` and resolves
535
+ dependencies from the SDK's virtual environment.
536
+
537
+ ## Pre-commit hooks
538
+
539
+ From the repository root, install the prek hooks once per checkout:
540
+
541
+ ```sh
542
+ uv run --locked --project sdk/py/infra prek install
543
+ ```
544
+
545
+ The hooks apply safe Ruff lint fixes, format the SDK, and check it with ty when
546
+ relevant SDK files change. If a hook changes files, stage those changes and commit
547
+ again. All hooks use the same locked tools as CI. To run them manually:
548
+
549
+ ```sh
550
+ uv run --locked --project sdk/py/infra prek run --all-files
551
+ ```
552
+
553
+ ## Object storage
554
+
555
+ Declare a bucket at module scope, then use its async methods:
556
+
557
+ ```python
558
+ from encore.storage import objects
559
+
560
+ uploads = objects.Bucket("uploads")
561
+
562
+
563
+ async def example() -> None:
564
+ await uploads.upload(
565
+ "hello.txt",
566
+ b"Hello!",
567
+ content_type="text/plain",
568
+ if_not_exists=True,
569
+ )
570
+ attrs = await uploads.attrs("hello.txt")
571
+ present = await uploads.exists("hello.txt")
572
+ part = await uploads.download("hello.txt", offset=0, length=5)
573
+ async for entry in uploads.list(prefix="hello", limit=10):
574
+ print(entry.name, entry.size)
575
+ download_url = await uploads.signed_download_url("hello.txt", ttl=3600)
576
+ await uploads.remove("hello.txt")
577
+ ```
578
+
579
+ `attrs`, `exists`, `download`, and `remove` accept a keyword-only `version`.
580
+ `signed_upload_url` and `signed_download_url` return URL strings (`str`);
581
+ TTL is in seconds, defaults to one hour, and must be between 1 and 604800.
582
+ `public_url` is synchronous and requires a public bucket. `Bucket.named(name)`
583
+ looks up an existing bucket configured for the service.
584
+
585
+ Storage failures raise `ObjectsError` (an `EncoreError` subclass), with
586
+ `ObjectNotFound`, `PreconditionFailed`, and `InvalidArgument` for specific cases.
587
+ `exists` returns False for missing objects and raises on other storage failures.
588
+ If leaving a listing early, use `contextlib.aclosing(bucket.list(...))` to close
589
+ the native stream promptly.
590
+
591
+ For large objects, `upload_stream(name, chunks)` consumes an asynchronous iterable
592
+ of `bytes` with backpressure. It supports the same `content_type` and
593
+ `if_not_exists` options as `upload`. The native pipe is limited to 64 KiB, and
594
+ each write across the native boundary is at most 64 KiB. S3 multipart uploads retain at most four 8 MiB part payloads
595
+ and apply backpressure while waiting for the provider; completed parts retain
596
+ only their metadata. Other providers may use their own upload buffers.
597
+ The input iterable owns its chunks, so avoid producing the entire object at once.
598
+ Producer exceptions and cancellation abort the upload. The caller owns closing
599
+ an input generator that needs cleanup; cancellation cannot undo an upload the
600
+ provider has already committed.
601
+
602
+ `download_stream` yields provider-sized byte chunks and supports `version`,
603
+ `offset`, and `length`. It reads on demand. Use `aclosing` when a loop might stop
604
+ early, including when piping a download into another upload:
605
+
606
+ ```python
607
+ from contextlib import aclosing
608
+
609
+
610
+ async def copy_large_object() -> None:
611
+ async with aclosing(uploads.download_stream("source.bin")) as chunks:
612
+ await uploads.upload_stream(
613
+ "copy.bin", chunks, content_type="application/octet-stream"
614
+ )
615
+ ```
616
+
617
+ ## Redis databases
618
+
619
+ Declare a database at module scope and call commands through its families:
620
+
621
+ ```python
622
+ from encore.storage.redis import Database, ReadOnlyDatabaseRef
623
+
624
+ sessions = Database("sessions")
625
+
626
+
627
+ async def save_user():
628
+ await sessions.string.set("user:1", b"hello", ex=60)
629
+ return await sessions.string.get("user:1") # bytes | None
630
+
631
+
632
+ async def load(reader: ReadOnlyDatabaseRef):
633
+ return await reader.string.get("user:1")
634
+
635
+
636
+ async def handle_request():
637
+ return await load(sessions.read())
638
+ ```
639
+
640
+ `Database(name)` has no persistence or eviction options yet. Creating a database,
641
+ selecting a family, and creating a handle defer native lookup until first use.
642
+ Direct resource calls must keep the family and command together, as in
643
+ `sessions.string.get(...)`, so Encore can determine their permissions. To pass
644
+ access to another function or keep a family interface in a variable, obtain a
645
+ `read()` or `write()` handle first.
646
+
647
+ Families are the properties `generic`, `string`, `bitmap`, `geo`, `hash`, `json`,
648
+ `list`, `set`, `sorted_set`, `stream`, and `pubsub`. Read handles expose
649
+ only read operations, and the native runtime also rejects writes through them.
650
+ `read(primary=True)` requests primary access; the current runtime uses the primary
651
+ for all reads, including when this flag is false.
652
+
653
+ Keys and hash/stream field names are `str` inputs. Stored values, members, and
654
+ message payloads accept `Value`: `bytes`, `str`, `int`, `float`, or an implementation of
655
+ `RedisValue`. Strings are encoded as UTF-8, integers as decimal text, floats as
656
+ ASCII text using `repr()`, and bytes are sent unchanged. Booleans are not
657
+ supported as stored values. Floats include `nan` and infinities, which can be
658
+ stored and retrieved but may be rejected by Redis numeric commands.
659
+
660
+ Reads return bytes by default. Select a type with `command[str](...)`,
661
+ `command[int](...)`, `command[float](...)`, or a custom value class to decode
662
+ stored values. The return type follows the requested type:
663
+
664
+ ```python
665
+ await sessions.string.set("visits", 42)
666
+ visits = await sessions.string.get[int]("visits") # int | None
667
+
668
+ await sessions.string.set("balance", 1.25)
669
+ balance = await sessions.string.get[float]("balance") # float | None
670
+
671
+ await sessions.list.rpush("names", "Ada", "Grace")
672
+ names = await sessions.list.lrange[str]("names", 0, -1) # list[str]
673
+ ```
674
+
675
+ `RedisValue` is a structural protocol: no base class or registration is required.
676
+ Implement `encode(self) -> bytes` and a class method `decode(cls, data: bytes)`
677
+ that returns an instance of the class:
678
+
679
+ ```python
680
+ import json
681
+ from dataclasses import dataclass
682
+
683
+
684
+ @dataclass
685
+ class User:
686
+ name: str
687
+
688
+ def encode(self) -> bytes:
689
+ return json.dumps({"name": self.name}).encode("utf-8")
690
+
691
+ @classmethod
692
+ def decode(cls, data: bytes) -> "User":
693
+ return cls(**json.loads(data))
694
+
695
+
696
+ await sessions.string.set("user:1", User("Ada"))
697
+ user = await sessions.string.get[User]("user:1") # User | None
698
+ await sessions.hash.hset("profiles", ("first", User("Ada")))
699
+ users = await sessions.hash.hgetall[User]("profiles") # dict[bytes, User]
700
+ ```
701
+
702
+ The same conversion applies to list elements, hash values, set/sorted-set/geo
703
+ members, stream field values, and pubsub payloads. For pubsub, use `subscribe[User](...)`
704
+ or `psubscribe[User](...)`; `Message.payload` then has that type.
705
+ Database keys, hash/stream field names, stream IDs, channel names, cursors,
706
+ scores, and other metadata retain their existing types. A custom value class
707
+ is not recorded in Redis: callers must choose the matching decoder.
708
+
709
+ Missing values remain `None`, and empty collections remain empty. Malformed
710
+ UTF-8, invalid integers, and custom codec errors raise instead of becoming
711
+ `None`. Reads that also mutate data (for example `getdel`, `lpop`, or `set` with
712
+ `get=True`) execute before decoding: a decoding error does not undo the command.
713
+ `set[User](..., get=True)` decodes the previous value; without `get=True`,
714
+ `set` only accepts the default bytes response mode.
715
+
716
+ Multi-value operations accept variadic arguments, with tuples for grouped values,
717
+ for example
718
+ `db.hash.hset("user", ("name", b"Ada"))` and
719
+ `db.string.mget("first", "second")`. Unpack existing collections with `*items`.
720
+ Most commands require at least one item; `geo.geohash`, `geo.geopos`, and
721
+ `json.get` allow none. `json.mget` requires a keyword-only `path`, and
722
+ `sorted_set.zinterstore`/`zunionstore` keep `weights` as a keyword sequence.
723
+ Commands with structured, option-dependent
724
+ replies (including streams and geo searches) retain the native RESP structure:
725
+ lists/dictionaries containing bytes, numbers, and `None`. With `withscores=True`,
726
+ sorted-set ranges return `(member: bytes, score: float)` pairs.
727
+
728
+ `string.set()` returns `None` normally; with `get=True` it returns the previous
729
+ value. With `nx=True` or `xx=True` and no `get`, it returns `b"OK"` if the write
730
+ happened and `None` otherwise.
731
+
732
+ RedisJSON methods accept Python JSON values and decode document replies:
733
+
734
+ ```python
735
+ await sessions.json.set("profile", "$", {"name": "Ada", "active": True})
736
+ profiles = await sessions.json.get("profile", "$")
737
+ ```
738
+
739
+ JSONPath replies preserve arrays of matches. JSON requires a server with
740
+ RedisJSON support; the local emulator does not currently implement it.
741
+
742
+ Redis channel subscriptions are separate from Encore's `pubsub.Topic` API. Use
743
+ an async context manager to release the dedicated connection on exit:
744
+
745
+ ```python
746
+ async def listen():
747
+ async with await sessions.pubsub.subscribe("events") as messages:
748
+ async for message in messages:
749
+ print(message.channel, message.payload) # both bytes
750
+ ```
751
+
752
+ `psubscribe()` accepts glob patterns. `Subscription.receive()` returns a `Message`
753
+ or `None` after closure; `close()` interrupts pending receives and is safe to
754
+ repeat. Leaving the context also closes the subscription when the body raises or
755
+ is cancelled.
756
+
757
+ `db.connection_string` (also available on write handles) returns the direct server
758
+ URL and grants broad read-write access. External clients using that URL do not
759
+ apply Encore's key prefixes or tracing. Read-only handles do not expose this URL.
760
+
761
+ ## Testing Postgres
762
+
763
+ Build the native runtime from the repository root:
764
+
765
+ ```sh
766
+ cargo xtask build-py-runtime
767
+ ```
768
+
769
+ Then run the Postgres tests from `sdk/py/infra`:
770
+
771
+ ```sh
772
+ uv sync --locked
773
+ uv run pytest tests/test_sqldb.py tests/test_postgres_runner.py -v
774
+ ```
775
+
776
+ The integration runner starts one disposable `postgres:17` Docker container and
777
+ removes it after the suite. To use an existing test server, set
778
+ `ENCORE_PYTHON_POSTGRES_TEST_HOST=localhost:5432`. The server must provide a
779
+ `postgres` database and user with password `encore-test` and schema creation
780
+ privileges. Use a dedicated test server.
781
+
782
+ `tests/test_postgres_runner.py` launches `tests/postgres_integration_suite.py`
783
+ in a separate process to isolate the native runtime configuration. The suite is
784
+ collected explicitly by the runner and excluded from normal pytest discovery
785
+ by its filename.
786
+ Each test and Hypothesis example gets a fresh schema. The suite includes
787
+ parameter round trips, generated transaction and cursor operation sequences,
788
+ and synchronized cancellation regressions. Hypothesis uses deterministic generation and reports minimized failing examples.
789
+ Sequences are repeatable for unchanged tests and the same Hypothesis version.
790
+ Resource cleanup and bounded polling live in `tests/postgres_test_support.py`.
791
+ These integration tests are run locally and are not executed by CI.