encore-dev 2.0.0a17__tar.gz → 2.0.0b5__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 (72) hide show
  1. encore_dev-2.0.0a17/README.md → encore_dev-2.0.0b5/PKG-INFO +290 -0
  2. encore_dev-2.0.0a17/PKG-INFO → encore_dev-2.0.0b5/README.md +270 -13
  3. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/__init__.py +33 -6
  4. encore_dev-2.0.0b5/encore/_run.py +246 -0
  5. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/_build.py +4 -4
  6. encore_dev-2.0.0b5/encore/errs/__init__.py +90 -0
  7. encore_dev-2.0.0b5/encore/gateway/__init__.py +990 -0
  8. encore_dev-2.0.0b5/encore/gateway/_serve.py +615 -0
  9. encore_dev-2.0.0b5/encore/gateway/match.py +270 -0
  10. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/_loader.py +39 -9
  11. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/_native.pyi +392 -13
  12. encore_dev-2.0.0b5/encore/internal/reqfields.py +337 -0
  13. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/runtime.py +0 -5
  14. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/pubsub/__init__.py +50 -6
  15. encore_dev-2.0.0b5/encore/reqctx/__init__.py +257 -0
  16. encore_dev-2.0.0b5/encore/reqctx/_json.py +77 -0
  17. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/secrets/__init__.py +61 -11
  18. encore_dev-2.0.0b5/encore/server/__init__.py +10 -0
  19. encore_dev-2.0.0b5/encore/server/_serve.py +475 -0
  20. encore_dev-2.0.0b5/encore/server/_span.py +100 -0
  21. encore_dev-2.0.0b5/encore/server/_tracing.py +212 -0
  22. encore_dev-2.0.0b5/encore/server/asgi.py +161 -0
  23. encore_dev-2.0.0b5/encore/server/wsgi.py +208 -0
  24. encore_dev-2.0.0b5/encore/services/__init__.py +54 -0
  25. encore_dev-2.0.0b5/encore/services/_propagate.py +88 -0
  26. encore_dev-2.0.0b5/encore/services/aiohttp.py +75 -0
  27. encore_dev-2.0.0b5/encore/services/httpx.py +137 -0
  28. encore_dev-2.0.0b5/encore/services/niquests.py +93 -0
  29. encore_dev-2.0.0b5/encore/services/requests.py +88 -0
  30. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/postgres/__init__.py +7 -0
  31. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_context.py +21 -0
  32. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_tracer.py +6 -0
  33. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/pyproject.toml +15 -3
  34. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/pyproject.toml.orig +25 -3
  35. encore_dev-2.0.0a17/encore/services/__init__.py +0 -32
  36. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/__init__.py +0 -0
  37. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/_markers.py +0 -0
  38. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/cron/__init__.py +0 -0
  39. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/env/__init__.py +0 -0
  40. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/__init__.py +0 -0
  41. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/lazy.py +0 -0
  42. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/startup.py +0 -0
  43. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/__init__.py +0 -0
  44. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/_console.py +0 -0
  45. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/_runtime.py +0 -0
  46. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/__init__.py +0 -0
  47. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_instrument.py +0 -0
  48. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_metrics.py +0 -0
  49. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_provider.py +0 -0
  50. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/py.typed +0 -0
  51. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/shutdown/__init__.py +0 -0
  52. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/__init__.py +0 -0
  53. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/objects/__init__.py +0 -0
  54. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/__init__.py +0 -0
  55. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_base.py +0 -0
  56. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_responses.py +0 -0
  57. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_values.py +0 -0
  58. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/bitmap.py +0 -0
  59. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/generic.py +0 -0
  60. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/geo.py +0 -0
  61. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/hash.py +0 -0
  62. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/json.py +0 -0
  63. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/list.py +0 -0
  64. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/pubsub.py +0 -0
  65. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/set.py +0 -0
  66. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/sorted_set.py +0 -0
  67. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/stream.py +0 -0
  68. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/string.py +0 -0
  69. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/__init__.py +0 -0
  70. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_encode.py +0 -0
  71. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_provider.py +0 -0
  72. {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_span.py +0 -0
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.3
2
+ Name: encore-dev
3
+ Version: 2.0.0b5
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: typing-extensions>=4.12
10
+ Requires-Dist: aiohttp>=3.14.4 ; python_full_version >= '3.10' and extra == 'aiohttp'
11
+ Requires-Dist: httpx>=0.27 ; extra == 'httpx'
12
+ Requires-Dist: niquests>=3.19 ; extra == 'niquests'
13
+ Requires-Dist: requests>=2.34 ; python_full_version >= '3.10' and extra == 'requests'
14
+ Requires-Python: >=3.9
15
+ Provides-Extra: aiohttp
16
+ Provides-Extra: httpx
17
+ Provides-Extra: niquests
18
+ Provides-Extra: requests
19
+ Description-Content-Type: text/markdown
20
+
1
21
  # Encore SDK for Python
2
22
 
3
23
  The Python SDK for [Encore](https://encore.dev) — **infrastructure resources only**.
@@ -40,6 +60,29 @@ async def place_order(order):
40
60
  await events.publish({"id": order.id})
41
61
  ```
42
62
 
63
+ ## Secrets
64
+
65
+ `secrets.Secret[T]("Name")` belongs to the service that declares it.
66
+ `secrets.SharedSecret[T]("Name")` is one secret for the whole app, given to the
67
+ services that read it, and `secrets.GlobalSecret[T]("Name")` one that every
68
+ service is given. `T` is `str` or `bytes`, and `value` reads the secret decoded
69
+ as that.
70
+
71
+ A shared secret is declared once in the app. Another Python service can't
72
+ import the declaring service's modules, so it references the secret by name
73
+ instead:
74
+
75
+ ```python
76
+ from encore.secrets import SharedSecret
77
+
78
+ support_email = SharedSecret[str].named("SupportEmail")
79
+ ```
80
+
81
+ Reading `value` makes the service one the secret is given to. The declaration
82
+ can be in any language, and the type argument is how this service decodes the
83
+ value, whatever the declaration's. Encore reports a reference to a name that
84
+ isn't declared as a shared secret.
85
+
43
86
  ## Service URLs
44
87
 
45
88
  Use `base_url` to look up a service's configured base URL:
@@ -58,6 +101,184 @@ Looking up a service's URL makes the calling service depend on it, which Encore
58
101
  reads from the code: pass the name as a string literal, call `base_url` directly
59
102
  rather than passing it around, and call it in a service.
60
103
 
104
+ ### Calling services
105
+
106
+ To call a service, use the adapter for your HTTP client. Each adapter re-exports
107
+ `encore.services` and adds `client(service)`, a client whose relative URLs are
108
+ the service's: the async one where the library has both kinds, with
109
+ `sync_client(service)` for the other:
110
+
111
+ ```python
112
+ from encore.services import httpx as services
113
+
114
+ billing = services.client("billing")
115
+
116
+
117
+ async def invoices():
118
+ response = await billing.get("/invoices")
119
+ return response.json()
120
+ ```
121
+
122
+ | Adapter | Install | `client` returns | `sync_client` returns |
123
+ |---|---|---|---|
124
+ | `encore.services.httpx` | `encore-dev[httpx]` | `httpx.AsyncClient` | `httpx.Client` |
125
+ | `encore.services.requests` | `encore-dev[requests]` | `requests.Session` | |
126
+ | `encore.services.aiohttp` | `encore-dev[aiohttp]` | `aiohttp.ClientSession` | |
127
+ | `encore.services.niquests` | `encore-dev[niquests]` | `niquests.AsyncSession` | `niquests.Session` |
128
+
129
+ The `requests` and `aiohttp` extras need Python 3.10: on 3.9 they install
130
+ nothing, as their releases for 3.9 have known vulnerabilities, and the adapters
131
+ use the client the app installed.
132
+
133
+ A generated client takes an `httpx.Client` as its `http_client`, so give it
134
+ `sync_client`. Their other arguments go to the client's constructor, as keywords. The same
135
+ rules as `base_url` apply to the service's name. aiohttp joins relative URLs as
136
+ links are, so write them without the leading slash there: `get("invoices")`;
137
+ and it creates a session only in a running event loop, so call its `client` in
138
+ a coroutine, such as a lifespan handler.
139
+
140
+ The clients send the Request Context of the calls made with them to the app's
141
+ services, and never to anything else, redirects included. A client you build
142
+ yourself can do the same with the adapter's hook: `Transport` and
143
+ `AsyncTransport` for httpx, the requests `Adapter`, the aiohttp `middleware`, or the
144
+ niquests `pre_request` hook.
145
+
146
+ ## Request Context
147
+
148
+ Request Context fields are typed values that travel with a request to every
149
+ service it reaches, such as the user it is made for. Declare a field once, at
150
+ module scope, with its name and the type of its value:
151
+
152
+ ```python
153
+ from dataclasses import dataclass
154
+
155
+ from encore import reqctx
156
+
157
+
158
+ @dataclass
159
+ class User:
160
+ id: str
161
+ admin: bool = False
162
+
163
+
164
+ current_user = reqctx.Field[User]("user")
165
+ tenant = reqctx.Field[str]("tenant", trace=True)
166
+ ```
167
+
168
+ Read a field while handling a request, and set or clear one for the calls made
169
+ in a block:
170
+
171
+ ```python
172
+ user = current_user.get() # None when not set
173
+
174
+ with current_user.set(User(id="system")):
175
+ await billing.get("/invoices")
176
+
177
+ with tenant.unset():
178
+ ...
179
+ ```
180
+
181
+ Reference a field declared elsewhere, in any language, by name and with its
182
+ value's type: `current_user = reqctx.Field[User].named("user")`. Encore checks
183
+ that the types agree.
184
+
185
+ - `pubsub=True` also carries the field on the Pub/Sub messages published with
186
+ it set, into the handlers that receive them.
187
+ - `trace=True` puts the value on the request's spans, as
188
+ `encore.ctx.<name>`. Use it for identifiers worth finding traces by, never
189
+ for secrets.
190
+
191
+ A request keeps every field it carries, including ones the service never
192
+ declares, and passes them on to the services it calls through the
193
+ `encore.services` clients. Fields are never sent to anything else. A field's
194
+ value may encode to at most 4 KiB (1 KiB with `pubsub`), and a request's fields
195
+ to at most 8 KiB in all: setting a value beyond that raises `ValueError`.
196
+
197
+ ## Running the service
198
+
199
+ `encore.run(app)` runs the service: it serves an ASGI app (FastAPI, Starlette,
200
+ ...) with Encore's server, built on hyper, Rust's HTTP library, and handles its
201
+ lifecycle.
202
+
203
+ ```python
204
+ import encore
205
+ from fastapi import FastAPI
206
+
207
+ app = FastAPI()
208
+
209
+ if __name__ == "__main__":
210
+ encore.run(app)
211
+ ```
212
+
213
+ - **Startup**: it binds the address Encore assigns the service, runs the app's
214
+ lifespan startup, starts listening, and starts Encore's Pub/Sub
215
+ subscriptions. A failed startup exits with 1.
216
+ - **Requests**: HTTP/1.1 and HTTP/2 (h2c). Each request has its Request Context
217
+ and a span continuing its trace, with no OpenTelemetry setup in the app. The
218
+ server records the span itself, from the request's arrival to its response,
219
+ named after the route the app matched; the app's code reaches it as
220
+ `trace.get_current_span()`.
221
+ - **Shutdown**: on SIGTERM or SIGINT it stops accepting connections and taking
222
+ Pub/Sub messages, lets the requests in flight finish, runs the app's lifespan
223
+ shutdown, and lets Encore clean up the resources the app used, all within
224
+ Encore's shutdown deadline. A request waiting for its client to go away, as a
225
+ stream of events does, is told it did. A second Ctrl-C stops waiting.
226
+
227
+ A service without an HTTP API, as one that only handles Pub/Sub messages, calls
228
+ `encore.run()`: the same, without the server.
229
+
230
+ - `workers=N` runs N event loops on threads, sharing the app. It needs
231
+ free-threaded Python with the GIL disabled (`python3.14t`), where they run in
232
+ parallel. Each worker runs the app's lifespan with its own `state`, so
233
+ resources bound to an event loop stay on theirs.
234
+ - `max_concurrency` caps the requests the app handles at once.
235
+ - `read_timeout` (30 seconds) bounds how long a client may take to send a
236
+ request's headers, a body of up to 64 KiB (answered with 408 when it takes
237
+ longer), or each part of a longer one (after which its request reads as its
238
+ client gone).
239
+ - `tracing=False` takes only the Request Context.
240
+ - WebSockets are not supported yet.
241
+
242
+ A WSGI app (Flask, Django's WSGI application) runs through a WSGI-to-ASGI
243
+ adapter, such as `a2wsgi`'s, which calls it on a thread pool in the request's
244
+ context, so its code reads the Request Context and its calls carry it on:
245
+
246
+ ```python
247
+ import encore
248
+ from a2wsgi import WSGIMiddleware
249
+ from flask import Flask
250
+
251
+ app = Flask(__name__)
252
+
253
+ if __name__ == "__main__":
254
+ encore.run(WSGIMiddleware(app))
255
+ ```
256
+
257
+ Its spans are not named after its routes: WSGI frameworks don't say which one
258
+ they matched.
259
+
260
+ FastAPI records spans of its own once a tracer provider is set, as `run` sets
261
+ Encore's: a second server span for each request, and spans of dependencies,
262
+ endpoints and serialization. `run` turns them off for its requests; pass
263
+ `server_spans=False` to leave the request's span, and those, to FastAPI.
264
+
265
+ An app served by a server of its own adds Encore's middleware instead, for the
266
+ same Request Context and tracing:
267
+
268
+ ```python
269
+ from encore.server import asgi, wsgi
270
+
271
+ app.add_middleware(asgi.Middleware) # FastAPI, Starlette
272
+ app = wsgi.Middleware(app) # Flask, Django's WSGI application
273
+ ```
274
+
275
+ Both make Encore's tracer provider OpenTelemetry's global one, unless the app
276
+ set its own. The middleware works beside OpenTelemetry's own: inside it, as
277
+ `FastAPIInstrumentor` arranges, the request's span is that one's; outside it,
278
+ OpenTelemetry's records an internal span under Encore's. The ASGI middleware
279
+ names the span after the route FastAPI or Starlette matched; WSGI frameworks
280
+ don't say which route they matched.
281
+
61
282
  ## Postgres queries and handles
62
283
 
63
284
  Declare `postgres.Database("orders", migrations="./migrations")` at module scope.
@@ -76,6 +297,16 @@ Entering the context does not execute SQL. Exiting it closes the query.
76
297
  `exec(sql, *params)` returns an `ExecResult` with `rows_affected`. Rows are
77
298
  dictionaries keyed by column name. Values are bound with `$1`, `$2`, etc.
78
299
 
300
+ Column values have the Python types asyncpg and psycopg use. `numeric` is a
301
+ `decimal.Decimal` with the column's scale, so a `numeric(10,2)` holding 7 is
302
+ `Decimal('7.00')`; `NaN` and `±Infinity` are `Decimal('NaN')` and
303
+ `Decimal('Infinity')`. `timestamptz` is an aware `datetime.datetime` in UTC,
304
+ `timestamp` a naive one, and `date` and `time` are `datetime.date` and
305
+ `datetime.time`. `bytea` is `bytes`, arrays are lists of their elements' types,
306
+ JSON is decoded, and `uuid`, `inet` and `cidr` are strings. Parameters accept
307
+ the same types; a naive `datetime` bound to a `timestamptz` is taken as UTC,
308
+ and an aware one may use any `tzinfo`, `zoneinfo.ZoneInfo` included.
309
+
79
310
  ```python
80
311
  async def find_order(database: postgres.Queryable, order_id: int):
81
312
  return await database.query("SELECT * FROM orders WHERE id = $1", order_id).first()
@@ -157,6 +388,65 @@ in a top-level Pydantic model and pass that class directly. Original bytes remai
157
388
  available as `message.raw_data`. Payloads are decoded once before the handler is
158
389
  called; decoding errors propagate for normal retry handling.
159
390
 
391
+ ## Gateway Middleware
392
+
393
+ A service with a `gateway` section in its `encore.service.json` hosts the app's
394
+ gateway, and its program serves it with Middleware: code that authenticates,
395
+ limits, logs or meters the public requests the gateway serves. Encore runs it on
396
+ the gateway variant of the native runtime.
397
+
398
+ ```python
399
+ import asyncio
400
+ from dataclasses import dataclass
401
+
402
+ import encore
403
+ from encore import gateway, reqctx
404
+ from encore.errs import ErrCode
405
+ from encore.gateway import match
406
+
407
+
408
+ @dataclass
409
+ class User:
410
+ id: str
411
+
412
+
413
+ current_user = reqctx.Field[User]("user")
414
+
415
+
416
+ class Authenticate(gateway.Middleware):
417
+ match = match.path_prefix("/api")
418
+
419
+ async def on_request(self, req: gateway.Request) -> None:
420
+ user = await verify(req.header("authorization"))
421
+ if user is None:
422
+ raise gateway.reject(ErrCode.UNAUTHENTICATED, "missing token")
423
+ req.set(current_user, user)
424
+
425
+
426
+ async def main() -> None:
427
+ await encore.start()
428
+ await gateway.serve(Authenticate())
429
+
430
+
431
+ asyncio.run(main())
432
+ ```
433
+
434
+ A Middleware is called at the hooks it defines (`on_request`, `on_request_body`,
435
+ `on_response`, `on_response_body`, `on_complete`), only for the requests its
436
+ `match` matches: Encore checks that before a request reaches Python. A hook may
437
+ read any header, query parameter or cookie; each reaches Python only as the hook
438
+ reads it. Encore checks a subclass when it is defined, so a misspelt hook raises
439
+ at once.
440
+
441
+ `on_request` sets the request's Request Context fields with
442
+ `req.set(field, value)`: the services the request reaches read them with
443
+ `field.get()`, and no client can set them. Every hook runs in the request's
444
+ trace and context, so it reads them with `field.get()` too, or `req.get(field)`,
445
+ and the calls it makes to services carry both.
446
+
447
+ `gateway.serve` handles SIGTERM while it serves, so hooks keep running while the
448
+ gateway drains, and returns once the shutdown has completed.
449
+
160
450
  ## Application-owned shutdown
161
451
 
162
452
  Without a hook, Encore shuts down non-cooperatively as the interpreter exits:
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.3
2
- Name: encore-dev
3
- Version: 2.0.0a17
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
1
  # Encore SDK for Python
15
2
 
16
3
  The Python SDK for [Encore](https://encore.dev) — **infrastructure resources only**.
@@ -53,6 +40,29 @@ async def place_order(order):
53
40
  await events.publish({"id": order.id})
54
41
  ```
55
42
 
43
+ ## Secrets
44
+
45
+ `secrets.Secret[T]("Name")` belongs to the service that declares it.
46
+ `secrets.SharedSecret[T]("Name")` is one secret for the whole app, given to the
47
+ services that read it, and `secrets.GlobalSecret[T]("Name")` one that every
48
+ service is given. `T` is `str` or `bytes`, and `value` reads the secret decoded
49
+ as that.
50
+
51
+ A shared secret is declared once in the app. Another Python service can't
52
+ import the declaring service's modules, so it references the secret by name
53
+ instead:
54
+
55
+ ```python
56
+ from encore.secrets import SharedSecret
57
+
58
+ support_email = SharedSecret[str].named("SupportEmail")
59
+ ```
60
+
61
+ Reading `value` makes the service one the secret is given to. The declaration
62
+ can be in any language, and the type argument is how this service decodes the
63
+ value, whatever the declaration's. Encore reports a reference to a name that
64
+ isn't declared as a shared secret.
65
+
56
66
  ## Service URLs
57
67
 
58
68
  Use `base_url` to look up a service's configured base URL:
@@ -71,6 +81,184 @@ Looking up a service's URL makes the calling service depend on it, which Encore
71
81
  reads from the code: pass the name as a string literal, call `base_url` directly
72
82
  rather than passing it around, and call it in a service.
73
83
 
84
+ ### Calling services
85
+
86
+ To call a service, use the adapter for your HTTP client. Each adapter re-exports
87
+ `encore.services` and adds `client(service)`, a client whose relative URLs are
88
+ the service's: the async one where the library has both kinds, with
89
+ `sync_client(service)` for the other:
90
+
91
+ ```python
92
+ from encore.services import httpx as services
93
+
94
+ billing = services.client("billing")
95
+
96
+
97
+ async def invoices():
98
+ response = await billing.get("/invoices")
99
+ return response.json()
100
+ ```
101
+
102
+ | Adapter | Install | `client` returns | `sync_client` returns |
103
+ |---|---|---|---|
104
+ | `encore.services.httpx` | `encore-dev[httpx]` | `httpx.AsyncClient` | `httpx.Client` |
105
+ | `encore.services.requests` | `encore-dev[requests]` | `requests.Session` | |
106
+ | `encore.services.aiohttp` | `encore-dev[aiohttp]` | `aiohttp.ClientSession` | |
107
+ | `encore.services.niquests` | `encore-dev[niquests]` | `niquests.AsyncSession` | `niquests.Session` |
108
+
109
+ The `requests` and `aiohttp` extras need Python 3.10: on 3.9 they install
110
+ nothing, as their releases for 3.9 have known vulnerabilities, and the adapters
111
+ use the client the app installed.
112
+
113
+ A generated client takes an `httpx.Client` as its `http_client`, so give it
114
+ `sync_client`. Their other arguments go to the client's constructor, as keywords. The same
115
+ rules as `base_url` apply to the service's name. aiohttp joins relative URLs as
116
+ links are, so write them without the leading slash there: `get("invoices")`;
117
+ and it creates a session only in a running event loop, so call its `client` in
118
+ a coroutine, such as a lifespan handler.
119
+
120
+ The clients send the Request Context of the calls made with them to the app's
121
+ services, and never to anything else, redirects included. A client you build
122
+ yourself can do the same with the adapter's hook: `Transport` and
123
+ `AsyncTransport` for httpx, the requests `Adapter`, the aiohttp `middleware`, or the
124
+ niquests `pre_request` hook.
125
+
126
+ ## Request Context
127
+
128
+ Request Context fields are typed values that travel with a request to every
129
+ service it reaches, such as the user it is made for. Declare a field once, at
130
+ module scope, with its name and the type of its value:
131
+
132
+ ```python
133
+ from dataclasses import dataclass
134
+
135
+ from encore import reqctx
136
+
137
+
138
+ @dataclass
139
+ class User:
140
+ id: str
141
+ admin: bool = False
142
+
143
+
144
+ current_user = reqctx.Field[User]("user")
145
+ tenant = reqctx.Field[str]("tenant", trace=True)
146
+ ```
147
+
148
+ Read a field while handling a request, and set or clear one for the calls made
149
+ in a block:
150
+
151
+ ```python
152
+ user = current_user.get() # None when not set
153
+
154
+ with current_user.set(User(id="system")):
155
+ await billing.get("/invoices")
156
+
157
+ with tenant.unset():
158
+ ...
159
+ ```
160
+
161
+ Reference a field declared elsewhere, in any language, by name and with its
162
+ value's type: `current_user = reqctx.Field[User].named("user")`. Encore checks
163
+ that the types agree.
164
+
165
+ - `pubsub=True` also carries the field on the Pub/Sub messages published with
166
+ it set, into the handlers that receive them.
167
+ - `trace=True` puts the value on the request's spans, as
168
+ `encore.ctx.<name>`. Use it for identifiers worth finding traces by, never
169
+ for secrets.
170
+
171
+ A request keeps every field it carries, including ones the service never
172
+ declares, and passes them on to the services it calls through the
173
+ `encore.services` clients. Fields are never sent to anything else. A field's
174
+ value may encode to at most 4 KiB (1 KiB with `pubsub`), and a request's fields
175
+ to at most 8 KiB in all: setting a value beyond that raises `ValueError`.
176
+
177
+ ## Running the service
178
+
179
+ `encore.run(app)` runs the service: it serves an ASGI app (FastAPI, Starlette,
180
+ ...) with Encore's server, built on hyper, Rust's HTTP library, and handles its
181
+ lifecycle.
182
+
183
+ ```python
184
+ import encore
185
+ from fastapi import FastAPI
186
+
187
+ app = FastAPI()
188
+
189
+ if __name__ == "__main__":
190
+ encore.run(app)
191
+ ```
192
+
193
+ - **Startup**: it binds the address Encore assigns the service, runs the app's
194
+ lifespan startup, starts listening, and starts Encore's Pub/Sub
195
+ subscriptions. A failed startup exits with 1.
196
+ - **Requests**: HTTP/1.1 and HTTP/2 (h2c). Each request has its Request Context
197
+ and a span continuing its trace, with no OpenTelemetry setup in the app. The
198
+ server records the span itself, from the request's arrival to its response,
199
+ named after the route the app matched; the app's code reaches it as
200
+ `trace.get_current_span()`.
201
+ - **Shutdown**: on SIGTERM or SIGINT it stops accepting connections and taking
202
+ Pub/Sub messages, lets the requests in flight finish, runs the app's lifespan
203
+ shutdown, and lets Encore clean up the resources the app used, all within
204
+ Encore's shutdown deadline. A request waiting for its client to go away, as a
205
+ stream of events does, is told it did. A second Ctrl-C stops waiting.
206
+
207
+ A service without an HTTP API, as one that only handles Pub/Sub messages, calls
208
+ `encore.run()`: the same, without the server.
209
+
210
+ - `workers=N` runs N event loops on threads, sharing the app. It needs
211
+ free-threaded Python with the GIL disabled (`python3.14t`), where they run in
212
+ parallel. Each worker runs the app's lifespan with its own `state`, so
213
+ resources bound to an event loop stay on theirs.
214
+ - `max_concurrency` caps the requests the app handles at once.
215
+ - `read_timeout` (30 seconds) bounds how long a client may take to send a
216
+ request's headers, a body of up to 64 KiB (answered with 408 when it takes
217
+ longer), or each part of a longer one (after which its request reads as its
218
+ client gone).
219
+ - `tracing=False` takes only the Request Context.
220
+ - WebSockets are not supported yet.
221
+
222
+ A WSGI app (Flask, Django's WSGI application) runs through a WSGI-to-ASGI
223
+ adapter, such as `a2wsgi`'s, which calls it on a thread pool in the request's
224
+ context, so its code reads the Request Context and its calls carry it on:
225
+
226
+ ```python
227
+ import encore
228
+ from a2wsgi import WSGIMiddleware
229
+ from flask import Flask
230
+
231
+ app = Flask(__name__)
232
+
233
+ if __name__ == "__main__":
234
+ encore.run(WSGIMiddleware(app))
235
+ ```
236
+
237
+ Its spans are not named after its routes: WSGI frameworks don't say which one
238
+ they matched.
239
+
240
+ FastAPI records spans of its own once a tracer provider is set, as `run` sets
241
+ Encore's: a second server span for each request, and spans of dependencies,
242
+ endpoints and serialization. `run` turns them off for its requests; pass
243
+ `server_spans=False` to leave the request's span, and those, to FastAPI.
244
+
245
+ An app served by a server of its own adds Encore's middleware instead, for the
246
+ same Request Context and tracing:
247
+
248
+ ```python
249
+ from encore.server import asgi, wsgi
250
+
251
+ app.add_middleware(asgi.Middleware) # FastAPI, Starlette
252
+ app = wsgi.Middleware(app) # Flask, Django's WSGI application
253
+ ```
254
+
255
+ Both make Encore's tracer provider OpenTelemetry's global one, unless the app
256
+ set its own. The middleware works beside OpenTelemetry's own: inside it, as
257
+ `FastAPIInstrumentor` arranges, the request's span is that one's; outside it,
258
+ OpenTelemetry's records an internal span under Encore's. The ASGI middleware
259
+ names the span after the route FastAPI or Starlette matched; WSGI frameworks
260
+ don't say which route they matched.
261
+
74
262
  ## Postgres queries and handles
75
263
 
76
264
  Declare `postgres.Database("orders", migrations="./migrations")` at module scope.
@@ -89,6 +277,16 @@ Entering the context does not execute SQL. Exiting it closes the query.
89
277
  `exec(sql, *params)` returns an `ExecResult` with `rows_affected`. Rows are
90
278
  dictionaries keyed by column name. Values are bound with `$1`, `$2`, etc.
91
279
 
280
+ Column values have the Python types asyncpg and psycopg use. `numeric` is a
281
+ `decimal.Decimal` with the column's scale, so a `numeric(10,2)` holding 7 is
282
+ `Decimal('7.00')`; `NaN` and `±Infinity` are `Decimal('NaN')` and
283
+ `Decimal('Infinity')`. `timestamptz` is an aware `datetime.datetime` in UTC,
284
+ `timestamp` a naive one, and `date` and `time` are `datetime.date` and
285
+ `datetime.time`. `bytea` is `bytes`, arrays are lists of their elements' types,
286
+ JSON is decoded, and `uuid`, `inet` and `cidr` are strings. Parameters accept
287
+ the same types; a naive `datetime` bound to a `timestamptz` is taken as UTC,
288
+ and an aware one may use any `tzinfo`, `zoneinfo.ZoneInfo` included.
289
+
92
290
  ```python
93
291
  async def find_order(database: postgres.Queryable, order_id: int):
94
292
  return await database.query("SELECT * FROM orders WHERE id = $1", order_id).first()
@@ -170,6 +368,65 @@ in a top-level Pydantic model and pass that class directly. Original bytes remai
170
368
  available as `message.raw_data`. Payloads are decoded once before the handler is
171
369
  called; decoding errors propagate for normal retry handling.
172
370
 
371
+ ## Gateway Middleware
372
+
373
+ A service with a `gateway` section in its `encore.service.json` hosts the app's
374
+ gateway, and its program serves it with Middleware: code that authenticates,
375
+ limits, logs or meters the public requests the gateway serves. Encore runs it on
376
+ the gateway variant of the native runtime.
377
+
378
+ ```python
379
+ import asyncio
380
+ from dataclasses import dataclass
381
+
382
+ import encore
383
+ from encore import gateway, reqctx
384
+ from encore.errs import ErrCode
385
+ from encore.gateway import match
386
+
387
+
388
+ @dataclass
389
+ class User:
390
+ id: str
391
+
392
+
393
+ current_user = reqctx.Field[User]("user")
394
+
395
+
396
+ class Authenticate(gateway.Middleware):
397
+ match = match.path_prefix("/api")
398
+
399
+ async def on_request(self, req: gateway.Request) -> None:
400
+ user = await verify(req.header("authorization"))
401
+ if user is None:
402
+ raise gateway.reject(ErrCode.UNAUTHENTICATED, "missing token")
403
+ req.set(current_user, user)
404
+
405
+
406
+ async def main() -> None:
407
+ await encore.start()
408
+ await gateway.serve(Authenticate())
409
+
410
+
411
+ asyncio.run(main())
412
+ ```
413
+
414
+ A Middleware is called at the hooks it defines (`on_request`, `on_request_body`,
415
+ `on_response`, `on_response_body`, `on_complete`), only for the requests its
416
+ `match` matches: Encore checks that before a request reaches Python. A hook may
417
+ read any header, query parameter or cookie; each reaches Python only as the hook
418
+ reads it. Encore checks a subclass when it is defined, so a misspelt hook raises
419
+ at once.
420
+
421
+ `on_request` sets the request's Request Context fields with
422
+ `req.set(field, value)`: the services the request reaches read them with
423
+ `field.get()`, and no client can set them. Every hook runs in the request's
424
+ trace and context, so it reads them with `field.get()` too, or `req.get(field)`,
425
+ and the calls it makes to services carry both.
426
+
427
+ `gateway.serve` handles SIGTERM while it serves, so hooks keep running while the
428
+ gateway drains, and returns once the shutdown has completed.
429
+
173
430
  ## Application-owned shutdown
174
431
 
175
432
  Without a hook, Encore shuts down non-cooperatively as the interpreter exits: