encore-dev 2.0.0b4__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 (71) hide show
  1. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/PKG-INFO +95 -3
  2. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/README.md +94 -2
  3. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/__init__.py +4 -0
  4. encore_dev-2.0.0b5/encore/errs/__init__.py +90 -0
  5. encore_dev-2.0.0b5/encore/gateway/__init__.py +990 -0
  6. encore_dev-2.0.0b5/encore/gateway/_serve.py +615 -0
  7. encore_dev-2.0.0b5/encore/gateway/match.py +270 -0
  8. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/_loader.py +39 -9
  9. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/_native.pyi +210 -0
  10. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/reqfields.py +54 -24
  11. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/pubsub/__init__.py +3 -3
  12. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/reqctx/__init__.py +7 -9
  13. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/secrets/__init__.py +55 -1
  14. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/__init__.py +5 -0
  15. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/postgres/__init__.py +7 -0
  16. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/pyproject.toml +1 -1
  17. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/pyproject.toml.orig +1 -1
  18. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/_run.py +0 -0
  19. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/config/__init__.py +0 -0
  20. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/config/_build.py +0 -0
  21. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/config/_markers.py +0 -0
  22. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/cron/__init__.py +0 -0
  23. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/env/__init__.py +0 -0
  24. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/__init__.py +0 -0
  25. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/lazy.py +0 -0
  26. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/runtime.py +0 -0
  27. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/internal/startup.py +0 -0
  28. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/logs/__init__.py +0 -0
  29. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/logs/_console.py +0 -0
  30. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/logs/_runtime.py +0 -0
  31. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/metrics/__init__.py +0 -0
  32. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/metrics/_instrument.py +0 -0
  33. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/metrics/_metrics.py +0 -0
  34. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/metrics/_provider.py +0 -0
  35. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/py.typed +0 -0
  36. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/reqctx/_json.py +0 -0
  37. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/__init__.py +0 -0
  38. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/_serve.py +0 -0
  39. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/_span.py +0 -0
  40. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/_tracing.py +0 -0
  41. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/asgi.py +0 -0
  42. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/server/wsgi.py +0 -0
  43. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/_propagate.py +0 -0
  44. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/aiohttp.py +0 -0
  45. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/httpx.py +0 -0
  46. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/niquests.py +0 -0
  47. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/services/requests.py +0 -0
  48. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/shutdown/__init__.py +0 -0
  49. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/__init__.py +0 -0
  50. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/objects/__init__.py +0 -0
  51. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/__init__.py +0 -0
  52. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/_base.py +0 -0
  53. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/_responses.py +0 -0
  54. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/_values.py +0 -0
  55. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/bitmap.py +0 -0
  56. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/generic.py +0 -0
  57. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/geo.py +0 -0
  58. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/hash.py +0 -0
  59. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/json.py +0 -0
  60. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/list.py +0 -0
  61. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/pubsub.py +0 -0
  62. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/set.py +0 -0
  63. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/sorted_set.py +0 -0
  64. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/stream.py +0 -0
  65. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/storage/redis/string.py +0 -0
  66. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/__init__.py +0 -0
  67. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/_context.py +0 -0
  68. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/_encode.py +0 -0
  69. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/_provider.py +0 -0
  70. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/_span.py +0 -0
  71. {encore_dev-2.0.0b4 → encore_dev-2.0.0b5}/encore/tracing/_tracer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: encore-dev
3
- Version: 2.0.0b4
3
+ Version: 2.0.0b5
4
4
  Summary: Encore SDK for Python — infrastructure resources
5
5
  Author: Encore
6
6
  License: MPL-2.0
@@ -60,6 +60,29 @@ async def place_order(order):
60
60
  await events.publish({"id": order.id})
61
61
  ```
62
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
+
63
86
  ## Service URLs
64
87
 
65
88
  Use `base_url` to look up a service's configured base URL:
@@ -139,7 +162,7 @@ class User:
139
162
 
140
163
 
141
164
  current_user = reqctx.Field[User]("user")
142
- tenant = reqctx.Field[str]("tenant", record=True)
165
+ tenant = reqctx.Field[str]("tenant", trace=True)
143
166
  ```
144
167
 
145
168
  Read a field while handling a request, and set or clear one for the calls made
@@ -161,7 +184,7 @@ that the types agree.
161
184
 
162
185
  - `pubsub=True` also carries the field on the Pub/Sub messages published with
163
186
  it set, into the handlers that receive them.
164
- - `record=True` records the value on the request's spans, as
187
+ - `trace=True` puts the value on the request's spans, as
165
188
  `encore.ctx.<name>`. Use it for identifiers worth finding traces by, never
166
189
  for secrets.
167
190
 
@@ -274,6 +297,16 @@ Entering the context does not execute SQL. Exiting it closes the query.
274
297
  `exec(sql, *params)` returns an `ExecResult` with `rows_affected`. Rows are
275
298
  dictionaries keyed by column name. Values are bound with `$1`, `$2`, etc.
276
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
+
277
310
  ```python
278
311
  async def find_order(database: postgres.Queryable, order_id: int):
279
312
  return await database.query("SELECT * FROM orders WHERE id = $1", order_id).first()
@@ -355,6 +388,65 @@ in a top-level Pydantic model and pass that class directly. Original bytes remai
355
388
  available as `message.raw_data`. Payloads are decoded once before the handler is
356
389
  called; decoding errors propagate for normal retry handling.
357
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
+
358
450
  ## Application-owned shutdown
359
451
 
360
452
  Without a hook, Encore shuts down non-cooperatively as the interpreter exits:
@@ -40,6 +40,29 @@ async def place_order(order):
40
40
  await events.publish({"id": order.id})
41
41
  ```
42
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
+
43
66
  ## Service URLs
44
67
 
45
68
  Use `base_url` to look up a service's configured base URL:
@@ -119,7 +142,7 @@ class User:
119
142
 
120
143
 
121
144
  current_user = reqctx.Field[User]("user")
122
- tenant = reqctx.Field[str]("tenant", record=True)
145
+ tenant = reqctx.Field[str]("tenant", trace=True)
123
146
  ```
124
147
 
125
148
  Read a field while handling a request, and set or clear one for the calls made
@@ -141,7 +164,7 @@ that the types agree.
141
164
 
142
165
  - `pubsub=True` also carries the field on the Pub/Sub messages published with
143
166
  it set, into the handlers that receive them.
144
- - `record=True` records the value on the request's spans, as
167
+ - `trace=True` puts the value on the request's spans, as
145
168
  `encore.ctx.<name>`. Use it for identifiers worth finding traces by, never
146
169
  for secrets.
147
170
 
@@ -254,6 +277,16 @@ Entering the context does not execute SQL. Exiting it closes the query.
254
277
  `exec(sql, *params)` returns an `ExecResult` with `rows_affected`. Rows are
255
278
  dictionaries keyed by column name. Values are bound with `$1`, `$2`, etc.
256
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
+
257
290
  ```python
258
291
  async def find_order(database: postgres.Queryable, order_id: int):
259
292
  return await database.query("SELECT * FROM orders WHERE id = $1", order_id).first()
@@ -335,6 +368,65 @@ in a top-level Pydantic model and pass that class directly. Original bytes remai
335
368
  available as `message.raw_data`. Payloads are decoded once before the handler is
336
369
  called; decoding errors propagate for normal retry handling.
337
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
+
338
430
  ## Application-owned shutdown
339
431
 
340
432
  Without a hook, Encore shuts down non-cooperatively as the interpreter exits:
@@ -36,6 +36,8 @@ from . import (
36
36
  config,
37
37
  cron,
38
38
  env,
39
+ errs,
40
+ gateway,
39
41
  metrics,
40
42
  pubsub,
41
43
  reqctx,
@@ -55,6 +57,8 @@ __all__ = [
55
57
  "pubsub",
56
58
  "cron",
57
59
  "env",
60
+ "errs",
61
+ "gateway",
58
62
  "metrics",
59
63
  "reqctx",
60
64
  "secrets",
@@ -0,0 +1,90 @@
1
+ """Encore's error codes, and the error a request is answered with.
2
+
3
+ An [APIError] is answered as Encore answers an error: with its code's HTTP
4
+ status, and a JSON body Encore's clients read as the error::
5
+
6
+ {"code": "unauthenticated", "message": "missing token", "details": null}
7
+
8
+ Typical usage example, in a gateway's Middleware::
9
+
10
+ from encore.errs import APIError, ErrCode
11
+
12
+ async def on_request(self, req):
13
+ if req.header("authorization") is None:
14
+ raise APIError(ErrCode.UNAUTHENTICATED, "missing token")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from enum import Enum
20
+
21
+ __all__ = ["APIError", "ErrCode"]
22
+
23
+
24
+ class ErrCode(Enum):
25
+ """An Encore error code, and the HTTP status it answers with.
26
+
27
+ The values are the codes as Encore's error bodies spell them.
28
+ """
29
+
30
+ CANCELED = "canceled"
31
+ UNKNOWN = "unknown"
32
+ INVALID_ARGUMENT = "invalid_argument"
33
+ DEADLINE_EXCEEDED = "deadline_exceeded"
34
+ NOT_FOUND = "not_found"
35
+ ALREADY_EXISTS = "already_exists"
36
+ PERMISSION_DENIED = "permission_denied"
37
+ RESOURCE_EXHAUSTED = "resource_exhausted"
38
+ FAILED_PRECONDITION = "failed_precondition"
39
+ ABORTED = "aborted"
40
+ OUT_OF_RANGE = "out_of_range"
41
+ UNIMPLEMENTED = "unimplemented"
42
+ INTERNAL = "internal"
43
+ UNAVAILABLE = "unavailable"
44
+ DATA_LOSS = "data_loss"
45
+ UNAUTHENTICATED = "unauthenticated"
46
+
47
+ @property
48
+ def http_status(self) -> int:
49
+ """The HTTP status the code answers with.
50
+
51
+ 401 for ``UNAUTHENTICATED``, 404 for ``NOT_FOUND``, and so on.
52
+ """
53
+ return _HTTP_STATUS[self]
54
+
55
+
56
+ _HTTP_STATUS = {
57
+ ErrCode.CANCELED: 499,
58
+ ErrCode.UNKNOWN: 500,
59
+ ErrCode.INVALID_ARGUMENT: 400,
60
+ ErrCode.DEADLINE_EXCEEDED: 504,
61
+ ErrCode.NOT_FOUND: 404,
62
+ ErrCode.ALREADY_EXISTS: 409,
63
+ ErrCode.PERMISSION_DENIED: 403,
64
+ ErrCode.RESOURCE_EXHAUSTED: 429,
65
+ ErrCode.FAILED_PRECONDITION: 400,
66
+ ErrCode.ABORTED: 409,
67
+ ErrCode.OUT_OF_RANGE: 400,
68
+ ErrCode.UNIMPLEMENTED: 501,
69
+ ErrCode.INTERNAL: 500,
70
+ ErrCode.UNAVAILABLE: 503,
71
+ ErrCode.DATA_LOSS: 500,
72
+ ErrCode.UNAUTHENTICATED: 401,
73
+ }
74
+
75
+
76
+ class APIError(Exception):
77
+ """An error with an Encore error code, answered as Encore answers one.
78
+
79
+ Attributes:
80
+ code: The error's code.
81
+ message: What went wrong, for the client.
82
+ """
83
+
84
+ def __init__(self, code: ErrCode, message: str) -> None:
85
+ """Make an error with ``code`` and ``message``."""
86
+ if not isinstance(code, ErrCode):
87
+ raise TypeError(f"code must be an ErrCode, not {type(code).__name__}")
88
+ super().__init__(f"{code.value}: {message}")
89
+ self.code = code
90
+ self.message = message