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.
- encore_dev-2.0.0a17/README.md → encore_dev-2.0.0b5/PKG-INFO +290 -0
- encore_dev-2.0.0a17/PKG-INFO → encore_dev-2.0.0b5/README.md +270 -13
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/__init__.py +33 -6
- encore_dev-2.0.0b5/encore/_run.py +246 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/_build.py +4 -4
- encore_dev-2.0.0b5/encore/errs/__init__.py +90 -0
- encore_dev-2.0.0b5/encore/gateway/__init__.py +990 -0
- encore_dev-2.0.0b5/encore/gateway/_serve.py +615 -0
- encore_dev-2.0.0b5/encore/gateway/match.py +270 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/_loader.py +39 -9
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/_native.pyi +392 -13
- encore_dev-2.0.0b5/encore/internal/reqfields.py +337 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/runtime.py +0 -5
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/pubsub/__init__.py +50 -6
- encore_dev-2.0.0b5/encore/reqctx/__init__.py +257 -0
- encore_dev-2.0.0b5/encore/reqctx/_json.py +77 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/secrets/__init__.py +61 -11
- encore_dev-2.0.0b5/encore/server/__init__.py +10 -0
- encore_dev-2.0.0b5/encore/server/_serve.py +475 -0
- encore_dev-2.0.0b5/encore/server/_span.py +100 -0
- encore_dev-2.0.0b5/encore/server/_tracing.py +212 -0
- encore_dev-2.0.0b5/encore/server/asgi.py +161 -0
- encore_dev-2.0.0b5/encore/server/wsgi.py +208 -0
- encore_dev-2.0.0b5/encore/services/__init__.py +54 -0
- encore_dev-2.0.0b5/encore/services/_propagate.py +88 -0
- encore_dev-2.0.0b5/encore/services/aiohttp.py +75 -0
- encore_dev-2.0.0b5/encore/services/httpx.py +137 -0
- encore_dev-2.0.0b5/encore/services/niquests.py +93 -0
- encore_dev-2.0.0b5/encore/services/requests.py +88 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/postgres/__init__.py +7 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_context.py +21 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_tracer.py +6 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/pyproject.toml +15 -3
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/pyproject.toml.orig +25 -3
- encore_dev-2.0.0a17/encore/services/__init__.py +0 -32
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/config/_markers.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/cron/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/env/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/lazy.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/internal/startup.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/_console.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/logs/_runtime.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_instrument.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_metrics.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/metrics/_provider.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/py.typed +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/shutdown/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/objects/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_base.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_responses.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/_values.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/bitmap.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/generic.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/geo.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/hash.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/json.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/list.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/pubsub.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/set.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/sorted_set.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/stream.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/storage/redis/string.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/__init__.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_encode.py +0 -0
- {encore_dev-2.0.0a17 → encore_dev-2.0.0b5}/encore/tracing/_provider.py +0 -0
- {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:
|