encore-dev 2.0.0a7__tar.gz → 2.0.0a8__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.0a7 → encore_dev-2.0.0a8}/PKG-INFO +51 -61
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/README.md +50 -60
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/__init__.py +6 -4
- encore_dev-2.0.0a8/encore/config/__init__.py +268 -0
- encore_dev-2.0.0a8/encore/config/_build.py +337 -0
- encore_dev-2.0.0a8/encore/config/_markers.py +61 -0
- {encore_dev-2.0.0a7/encore/meta → encore_dev-2.0.0a8/encore/env}/__init__.py +2 -2
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/_native.pyi +128 -42
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/runtime.py +4 -5
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/startup.py +6 -0
- encore_dev-2.0.0a8/encore/metrics/__init__.py +159 -0
- encore_dev-2.0.0a8/encore/metrics/_instrument.py +179 -0
- encore_dev-2.0.0a8/encore/metrics/_metrics.py +458 -0
- encore_dev-2.0.0a8/encore/metrics/_provider.py +276 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/secrets/__init__.py +14 -1
- encore_dev-2.0.0a8/encore/services/__init__.py +32 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/postgres/__init__.py +46 -13
- encore_dev-2.0.0a8/encore/tracing/__init__.py +27 -0
- encore_dev-2.0.0a8/encore/tracing/_provider.py +45 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/pyproject.toml +1 -1
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/pyproject.toml.orig +1 -1
- encore_dev-2.0.0a7/encore/metrics/__init__.py +0 -390
- encore_dev-2.0.0a7/encore/tracing/__init__.py +0 -19
- encore_dev-2.0.0a7/encore/tracing/_provider.py +0 -62
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/cron/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/_loader.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/lazy.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/_console.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/_runtime.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/pubsub/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/py.typed +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/shutdown/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/objects/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/__init__.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_base.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_responses.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_values.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/bitmap.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/generic.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/geo.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/hash.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/json.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/list.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/pubsub.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/set.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/sorted_set.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/stream.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/string.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_context.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_encode.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_span.py +0 -0
- {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/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.
|
|
3
|
+
Version: 2.0.0a8
|
|
4
4
|
Summary: Encore SDK for Python — infrastructure resources
|
|
5
5
|
Author: Encore
|
|
6
6
|
License: MPL-2.0
|
|
@@ -40,6 +40,24 @@ async def place_order(order):
|
|
|
40
40
|
await events.publish({"id": order.id})
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
## Service URLs
|
|
44
|
+
|
|
45
|
+
Use `base_url` to look up a service's configured base URL:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from encore.services import base_url
|
|
49
|
+
|
|
50
|
+
order = f"{base_url('orders')}/orders/123"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The returned string has no trailing slash: a call's path goes after it. Unknown
|
|
54
|
+
service names raise `ValueError`. Lookup reads the runtime configuration and
|
|
55
|
+
makes no network request.
|
|
56
|
+
|
|
57
|
+
Looking up a service's URL makes the calling service depend on it, which Encore
|
|
58
|
+
reads from the code: pass the name as a string literal, call `base_url` directly
|
|
59
|
+
rather than passing it around, and call it in a service.
|
|
60
|
+
|
|
43
61
|
## Postgres queries and handles
|
|
44
62
|
|
|
45
63
|
Declare `postgres.Database("orders", migrations="./migrations")` at module scope.
|
|
@@ -73,9 +91,10 @@ select `read()`, `write()`, or `admin()` before handing it to another function.
|
|
|
73
91
|
Direct database operations infer writer permissions. Reference creation records
|
|
74
92
|
reader, writer, or admin permissions at that call site.
|
|
75
93
|
|
|
76
|
-
Like the Rust SDK,
|
|
77
|
-
|
|
78
|
-
|
|
94
|
+
Like the Rust SDK, handles share the database's pool. A `read()` handle runs on
|
|
95
|
+
read-only connections, so its writes fail with SQLSTATE 25006 and its
|
|
96
|
+
transactions are read-only; it reads from the database's read-only pool on a
|
|
97
|
+
replica if the runtime config has one, unless `primary=True` is set.
|
|
79
98
|
|
|
80
99
|
```python
|
|
81
100
|
async def rename_order(order_id: int, name: str):
|
|
@@ -412,10 +431,12 @@ uv run --project sdk/py/infra ruff format --check sdk/py/infra
|
|
|
412
431
|
The generated `_native.pyi` is excluded from linting and formatting because it
|
|
413
432
|
is owned by the PyO3 stub generator. It is still checked by ty.
|
|
414
433
|
|
|
415
|
-
##
|
|
434
|
+
## Metrics
|
|
416
435
|
|
|
417
|
-
|
|
418
|
-
|
|
436
|
+
Metrics are OpenTelemetry instruments of the meter `encore`, declared at module
|
|
437
|
+
scope in three scopes (`Counter`, `SharedCounter`, `GlobalCounter`, and so on
|
|
438
|
+
for `UpDownCounter`, `Gauge` and `Histogram`), each with a `…Group` form whose
|
|
439
|
+
labels are a `TypedDict` or a dataclass:
|
|
419
440
|
|
|
420
441
|
```python
|
|
421
442
|
from dataclasses import dataclass
|
|
@@ -425,74 +446,43 @@ from encore import metrics
|
|
|
425
446
|
|
|
426
447
|
@dataclass(frozen=True)
|
|
427
448
|
class Labels:
|
|
428
|
-
|
|
449
|
+
route: str
|
|
429
450
|
|
|
430
451
|
|
|
431
|
-
|
|
432
|
-
usage = metrics.CounterGroup[Labels, float]("usage")
|
|
433
|
-
cpu_time = metrics.Counter[float]("cpu_time")
|
|
452
|
+
latency = metrics.HistogramGroup[Labels]("request_duration", unit="s")
|
|
434
453
|
|
|
435
|
-
|
|
436
|
-
usage.with_labels(Labels("checkout")).increment(0.125)
|
|
437
|
-
cpu_time.increment(0.025)
|
|
454
|
+
latency.with_labels(Labels("/orders")).record(0.025)
|
|
438
455
|
```
|
|
439
456
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
`
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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.
|
|
457
|
+
A service's own metric (`Counter`, `HistogramGroup`, …) is exported as
|
|
458
|
+
`<service>.<name>`: `request_duration` of the service `shop` is
|
|
459
|
+
`shop.request_duration`. Shared and global ones keep their name, and are
|
|
460
|
+
declared once: other modules import the declaration, and code in another
|
|
461
|
+
language binds one by name, as in
|
|
462
|
+
`metrics.CounterGroup[Labels].named("responses_total")`. Hand a metric to other
|
|
463
|
+
code as a reference, `latency.ref()`.
|
|
451
464
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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.
|
|
465
|
+
Label keys are snake_case; values are strings, ints and bools, anything else a
|
|
466
|
+
`TypeError`. `unit`, `description` and a histogram's `buckets` must be literals:
|
|
467
|
+
Encore's parser reads them.
|
|
459
468
|
|
|
460
|
-
##
|
|
469
|
+
## OpenTelemetry
|
|
461
470
|
|
|
462
|
-
|
|
463
|
-
|
|
471
|
+
Encore records its spans and metrics through its own providers and installs no
|
|
472
|
+
OpenTelemetry globals, leaving an app's own setup alone. For the app's spans
|
|
473
|
+
and instruments, and instrumentation packages', install Encore's providers or
|
|
474
|
+
pass them to an instrumentation:
|
|
464
475
|
|
|
465
476
|
```python
|
|
466
|
-
from
|
|
467
|
-
|
|
468
|
-
from encore import metrics
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
class Labels(TypedDict):
|
|
472
|
-
route: str
|
|
477
|
+
from opentelemetry import metrics, trace
|
|
473
478
|
|
|
479
|
+
from encore.metrics import meter_provider
|
|
480
|
+
from encore.tracing import tracer_provider
|
|
474
481
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
latency.observe(0.025)
|
|
479
|
-
sizes.with_labels({"route": "/orders"}).observe(512)
|
|
482
|
+
trace.set_tracer_provider(tracer_provider())
|
|
483
|
+
metrics.set_meter_provider(meter_provider())
|
|
480
484
|
```
|
|
481
485
|
|
|
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
486
|
## Documentation style
|
|
497
487
|
|
|
498
488
|
Follow the [Google Python docstring guide](https://google.github.io/styleguide/pyguide.html#docstrings)
|
|
@@ -27,6 +27,24 @@ async def place_order(order):
|
|
|
27
27
|
await events.publish({"id": order.id})
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Service URLs
|
|
31
|
+
|
|
32
|
+
Use `base_url` to look up a service's configured base URL:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from encore.services import base_url
|
|
36
|
+
|
|
37
|
+
order = f"{base_url('orders')}/orders/123"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The returned string has no trailing slash: a call's path goes after it. Unknown
|
|
41
|
+
service names raise `ValueError`. Lookup reads the runtime configuration and
|
|
42
|
+
makes no network request.
|
|
43
|
+
|
|
44
|
+
Looking up a service's URL makes the calling service depend on it, which Encore
|
|
45
|
+
reads from the code: pass the name as a string literal, call `base_url` directly
|
|
46
|
+
rather than passing it around, and call it in a service.
|
|
47
|
+
|
|
30
48
|
## Postgres queries and handles
|
|
31
49
|
|
|
32
50
|
Declare `postgres.Database("orders", migrations="./migrations")` at module scope.
|
|
@@ -60,9 +78,10 @@ select `read()`, `write()`, or `admin()` before handing it to another function.
|
|
|
60
78
|
Direct database operations infer writer permissions. Reference creation records
|
|
61
79
|
reader, writer, or admin permissions at that call site.
|
|
62
80
|
|
|
63
|
-
Like the Rust SDK,
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
Like the Rust SDK, handles share the database's pool. A `read()` handle runs on
|
|
82
|
+
read-only connections, so its writes fail with SQLSTATE 25006 and its
|
|
83
|
+
transactions are read-only; it reads from the database's read-only pool on a
|
|
84
|
+
replica if the runtime config has one, unless `primary=True` is set.
|
|
66
85
|
|
|
67
86
|
```python
|
|
68
87
|
async def rename_order(order_id: int, name: str):
|
|
@@ -399,10 +418,12 @@ uv run --project sdk/py/infra ruff format --check sdk/py/infra
|
|
|
399
418
|
The generated `_native.pyi` is excluded from linting and formatting because it
|
|
400
419
|
is owned by the PyO3 stub generator. It is still checked by ty.
|
|
401
420
|
|
|
402
|
-
##
|
|
421
|
+
## Metrics
|
|
403
422
|
|
|
404
|
-
|
|
405
|
-
|
|
423
|
+
Metrics are OpenTelemetry instruments of the meter `encore`, declared at module
|
|
424
|
+
scope in three scopes (`Counter`, `SharedCounter`, `GlobalCounter`, and so on
|
|
425
|
+
for `UpDownCounter`, `Gauge` and `Histogram`), each with a `…Group` form whose
|
|
426
|
+
labels are a `TypedDict` or a dataclass:
|
|
406
427
|
|
|
407
428
|
```python
|
|
408
429
|
from dataclasses import dataclass
|
|
@@ -412,74 +433,43 @@ from encore import metrics
|
|
|
412
433
|
|
|
413
434
|
@dataclass(frozen=True)
|
|
414
435
|
class Labels:
|
|
415
|
-
|
|
436
|
+
route: str
|
|
416
437
|
|
|
417
438
|
|
|
418
|
-
|
|
419
|
-
usage = metrics.CounterGroup[Labels, float]("usage")
|
|
420
|
-
cpu_time = metrics.Counter[float]("cpu_time")
|
|
439
|
+
latency = metrics.HistogramGroup[Labels]("request_duration", unit="s")
|
|
421
440
|
|
|
422
|
-
|
|
423
|
-
usage.with_labels(Labels("checkout")).increment(0.125)
|
|
424
|
-
cpu_time.increment(0.025)
|
|
441
|
+
latency.with_labels(Labels("/orders")).record(0.025)
|
|
425
442
|
```
|
|
426
443
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
`
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
the total raises `ValueError` without changing it. Double-precision totals can
|
|
435
|
-
incur rounding, including loss of integer precision above `2**53`. Integer and
|
|
436
|
-
float counters cannot share a native series. Integer totals still wrap on 64-bit
|
|
437
|
-
overflow. Calling `increment()` without a value adds one in either mode.
|
|
444
|
+
A service's own metric (`Counter`, `HistogramGroup`, …) is exported as
|
|
445
|
+
`<service>.<name>`: `request_duration` of the service `shop` is
|
|
446
|
+
`shop.request_duration`. Shared and global ones keep their name, and are
|
|
447
|
+
declared once: other modules import the declaration, and code in another
|
|
448
|
+
language binds one by name, as in
|
|
449
|
+
`metrics.CounterGroup[Labels].named("responses_total")`. Hand a metric to other
|
|
450
|
+
code as a reference, `latency.ref()`.
|
|
438
451
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
booleans become `"true"` or `"false"`. Names are normalized to snake_case; `service`
|
|
443
|
-
is reserved. The label values are captured when binding, so later mutation does
|
|
444
|
-
not change an existing series. `GaugeGroup[Labels]` and `HistogramGroup[Labels]`
|
|
445
|
-
also provide typed label arguments.
|
|
452
|
+
Label keys are snake_case; values are strings, ints and bools, anything else a
|
|
453
|
+
`TypeError`. `unit`, `description` and a histogram's `buckets` must be literals:
|
|
454
|
+
Encore's parser reads them.
|
|
446
455
|
|
|
447
|
-
##
|
|
456
|
+
## OpenTelemetry
|
|
448
457
|
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
Encore records its spans and metrics through its own providers and installs no
|
|
459
|
+
OpenTelemetry globals, leaving an app's own setup alone. For the app's spans
|
|
460
|
+
and instruments, and instrumentation packages', install Encore's providers or
|
|
461
|
+
pass them to an instrumentation:
|
|
451
462
|
|
|
452
463
|
```python
|
|
453
|
-
from
|
|
454
|
-
|
|
455
|
-
from encore import metrics
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
class Labels(TypedDict):
|
|
459
|
-
route: str
|
|
464
|
+
from opentelemetry import metrics, trace
|
|
460
465
|
|
|
466
|
+
from encore.metrics import meter_provider
|
|
467
|
+
from encore.tracing import tracer_provider
|
|
461
468
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
latency.observe(0.025)
|
|
466
|
-
sizes.with_labels({"route": "/orders"}).observe(512)
|
|
469
|
+
trace.set_tracer_provider(tracer_provider())
|
|
470
|
+
metrics.set_meter_provider(meter_provider())
|
|
467
471
|
```
|
|
468
472
|
|
|
469
|
-
The runtime maintains count, sum, and automatic exponential buckets without
|
|
470
|
-
keeping individual measurements. Bucket widths grow by at most 10%. `unit` is
|
|
471
|
-
optional metadata, must be a string literal when supplied, and does not convert
|
|
472
|
-
values. Histogram groups normalize and cache labels like counter groups. Use
|
|
473
|
-
labels with a small set of values: each combination retains a series for the
|
|
474
|
-
process lifetime.
|
|
475
|
-
|
|
476
|
-
Prefer finite observations. As in Rust, NaN increments the count and makes the
|
|
477
|
-
sum NaN without entering a bucket; infinities enter their own buckets.
|
|
478
|
-
|
|
479
|
-
Prometheus remote write exports the bucket distribution for approximate
|
|
480
|
-
percentile queries. AWS, GCP, and Datadog currently export only `<name>_count`
|
|
481
|
-
and `<name>_sum`, which support averages but not percentiles.
|
|
482
|
-
|
|
483
473
|
## Documentation style
|
|
484
474
|
|
|
485
475
|
Follow the [Google Python docstring guide](https://google.github.io/styleguide/pyguide.html#docstrings)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Encore SDK for Python — infrastructure resources.
|
|
2
2
|
|
|
3
3
|
Declare infrastructure (SQL databases, pub/sub, object storage, Redis, cron jobs,
|
|
4
|
-
metrics, secrets) as module-level variables; Encore's parser discovers them for
|
|
4
|
+
metrics, secrets, config) as module-level variables; Encore's parser discovers them for
|
|
5
5
|
provisioning and wiring, and this package's runtime binding (``encore_sdk_core`` over
|
|
6
6
|
PyO3) connects them to the provisioned infrastructure at run time.
|
|
7
7
|
|
|
@@ -22,18 +22,20 @@ handlers on the application's loop and returns so the application can serve.
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
from . import cron,
|
|
25
|
+
from . import config, cron, env, metrics, pubsub, secrets, services, shutdown, storage
|
|
26
26
|
from .internal.runtime import EncoreError, run_forever, version
|
|
27
27
|
from .internal.startup import start
|
|
28
|
-
from .
|
|
28
|
+
from .env import app_meta
|
|
29
29
|
|
|
30
30
|
__all__ = [
|
|
31
|
+
"config",
|
|
31
32
|
"storage",
|
|
32
33
|
"pubsub",
|
|
33
34
|
"cron",
|
|
34
|
-
"
|
|
35
|
+
"env",
|
|
35
36
|
"metrics",
|
|
36
37
|
"secrets",
|
|
38
|
+
"services",
|
|
37
39
|
"shutdown",
|
|
38
40
|
"EncoreError",
|
|
39
41
|
"app_meta",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Application config: typed values a service reads.
|
|
2
|
+
|
|
3
|
+
Values are written in TOML or JSON files beside the code, and can change
|
|
4
|
+
while the app runs.
|
|
5
|
+
|
|
6
|
+
Typical usage example:
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from encore.config import Config, Max, Min
|
|
13
|
+
from encore.secrets import Secret
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Checkout:
|
|
18
|
+
max_items: Annotated[int, Min(1), Max(500)]
|
|
19
|
+
payment_timeout: timedelta = timedelta(seconds=90)
|
|
20
|
+
stripe_key: Secret[str] = Secret[str]("StripeKey")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
checkout = Config[Checkout]("checkout")
|
|
24
|
+
|
|
25
|
+
limit = checkout.value.max_items
|
|
26
|
+
|
|
27
|
+
Scope:
|
|
28
|
+
Which class a config is declared with says who it belongs to, as with
|
|
29
|
+
secrets. A Config belongs to the service that declares it, which holds
|
|
30
|
+
its own value for it, and must be declared inside a service. A
|
|
31
|
+
SharedConfig has one value for the whole app; each service that reads it
|
|
32
|
+
is recorded as using it. A GlobalConfig has one value for the whole app
|
|
33
|
+
and is available to every service without being recorded.
|
|
34
|
+
|
|
35
|
+
Values:
|
|
36
|
+
A config's value is a snapshot of its type: ``value`` never fails, since
|
|
37
|
+
the runtime checked the value against the config's schema before the
|
|
38
|
+
service started. Encore derives that schema from the type: a dataclass,
|
|
39
|
+
a TypedDict or an Enum, built of str, int, float, bool, Literal, Optional,
|
|
40
|
+
list, dict[str, ...], typing.Any and datetime.timedelta (written as a
|
|
41
|
+
string such as "30s"). A dataclass field with a default, and an Optional
|
|
42
|
+
one, may be left out. Constraints are Annotated markers: Min, Max,
|
|
43
|
+
MinLen, MaxLen, Pattern, MinItems and MaxItems.
|
|
44
|
+
|
|
45
|
+
A field of type Secret reads a secret rather than holding a value; its
|
|
46
|
+
default names it: ``api_key: Secret[str] = Secret[str]("StripeKey")``.
|
|
47
|
+
The secret's scope is the config's: a Config reads its service's own
|
|
48
|
+
secret, a SharedConfig a shared one, a GlobalConfig a global one. The
|
|
49
|
+
secret is declared in code as usual, with that scope.
|
|
50
|
+
|
|
51
|
+
Changes:
|
|
52
|
+
A config's value can change while the app runs: an edit to its files
|
|
53
|
+
under ``encore run``, a new value from Encore Cloud. Each read of
|
|
54
|
+
``value`` gives the newest; keep the snapshot for as long as one
|
|
55
|
+
consistent value is wanted. on_change calls a function with each new
|
|
56
|
+
value, on a thread of its own; values that come faster than it is called
|
|
57
|
+
are coalesced to the latest.
|
|
58
|
+
|
|
59
|
+
Files:
|
|
60
|
+
A config's value is written in a file named after it,
|
|
61
|
+
``checkout.config.toml`` (or ``.json``), beside the declaring service's
|
|
62
|
+
``encore.service.json``, or beside ``encore.app`` for a shared or global
|
|
63
|
+
config. Overlays change it for some environments only:
|
|
64
|
+
``checkout.production.config.toml`` for an environment type,
|
|
65
|
+
``checkout.@prod-eu.config.toml`` for one environment, and
|
|
66
|
+
``checkout.user.config.toml``, the user override, for a developer's own
|
|
67
|
+
``encore run`` (it is gitignored, and never read in tests).
|
|
68
|
+
|
|
69
|
+
Tests:
|
|
70
|
+
Under ``encore test`` a config has its ``test`` overlay's value.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
import json
|
|
76
|
+
import logging
|
|
77
|
+
import threading
|
|
78
|
+
from typing import Any, Callable, Generic, Optional, Tuple, TypeVar, get_args
|
|
79
|
+
|
|
80
|
+
from ..internal.lazy import Lazy
|
|
81
|
+
from ..internal.runtime import _native, runtime
|
|
82
|
+
from ._build import ConfigMismatchError, build
|
|
83
|
+
from ._markers import Max, MaxItems, MaxLen, Min, MinItems, MinLen, Pattern
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"Config",
|
|
87
|
+
"ConfigMismatchError",
|
|
88
|
+
"GlobalConfig",
|
|
89
|
+
"Max",
|
|
90
|
+
"MaxItems",
|
|
91
|
+
"MaxLen",
|
|
92
|
+
"Min",
|
|
93
|
+
"MinItems",
|
|
94
|
+
"MinLen",
|
|
95
|
+
"Pattern",
|
|
96
|
+
"SharedConfig",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
_T = TypeVar("_T")
|
|
100
|
+
|
|
101
|
+
_log = logging.getLogger("encore.config")
|
|
102
|
+
|
|
103
|
+
#: How long a change listener waits for a new value before looking again
|
|
104
|
+
#: whether it has been stopped.
|
|
105
|
+
_POLL = 0.5
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Config(Generic[_T]):
|
|
109
|
+
"""Common public behavior, backed by a scope-specific lookup."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self, name: str, load: Callable[[str], Optional[_native.Config]]
|
|
113
|
+
) -> None:
|
|
114
|
+
self._name = name
|
|
115
|
+
self._handle = Lazy(lambda: load(name))
|
|
116
|
+
self._lock = threading.Lock()
|
|
117
|
+
# The value built for a revision: (revision, value).
|
|
118
|
+
self._built: Optional[Tuple[int, Any]] = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
"""The name the config was declared with."""
|
|
123
|
+
return self._name
|
|
124
|
+
|
|
125
|
+
def _type(self) -> Any:
|
|
126
|
+
args = get_args(getattr(self, "__orig_class__", None))
|
|
127
|
+
if not args:
|
|
128
|
+
raise TypeError(
|
|
129
|
+
f"Use {type(self).__name__}[T](name) with the config's type, such as "
|
|
130
|
+
f"{type(self).__name__}[Checkout]({self._name!r})"
|
|
131
|
+
)
|
|
132
|
+
return args[0]
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def value(self) -> _T:
|
|
136
|
+
"""The config's value now, as its type.
|
|
137
|
+
|
|
138
|
+
A snapshot: built once for each new value, and shared by every read
|
|
139
|
+
until the value changes.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
TypeError: The config was declared without its type argument.
|
|
143
|
+
ConfigMismatchError: The value does not fit the type although it
|
|
144
|
+
satisfied the schema Encore derived from it: a bug in Encore.
|
|
145
|
+
"""
|
|
146
|
+
tp = self._type()
|
|
147
|
+
handle = self._handle.get()
|
|
148
|
+
if handle is None:
|
|
149
|
+
# The runtime has no value for it: the type's defaults are it.
|
|
150
|
+
return self._value_of(tp, -1, "{}")
|
|
151
|
+
revision, text = handle.current()
|
|
152
|
+
return self._value_of(tp, revision, text)
|
|
153
|
+
|
|
154
|
+
def _value_of(self, tp: Any, revision: int, text: str) -> Any:
|
|
155
|
+
"""The value of one revision, built once for it."""
|
|
156
|
+
built = self._built
|
|
157
|
+
if built is not None and built[0] == revision:
|
|
158
|
+
return built[1]
|
|
159
|
+
result = build(tp, json.loads(text), self._name)
|
|
160
|
+
with self._lock:
|
|
161
|
+
# Another thread may have built this revision meanwhile: one
|
|
162
|
+
# revision is one snapshot.
|
|
163
|
+
if self._built is not None and self._built[0] == revision:
|
|
164
|
+
return self._built[1]
|
|
165
|
+
# Only a newer revision replaces the snapshot: a listener may
|
|
166
|
+
# build one that the value has since moved on from.
|
|
167
|
+
if self._built is None or self._built[0] < revision:
|
|
168
|
+
self._built = (revision, result)
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
def on_change(self, fn: Callable[[_T], None]) -> Callable[[], None]:
|
|
172
|
+
"""Call ``fn`` with each new value the config is given from now on.
|
|
173
|
+
|
|
174
|
+
Values that come faster than ``fn`` is called are coalesced: ``fn``
|
|
175
|
+
gets the latest, once. ``fn`` runs on a thread of its own; keep it
|
|
176
|
+
quick. An exception it raises is logged, and later values still reach
|
|
177
|
+
it.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A function that stops the calls.
|
|
181
|
+
"""
|
|
182
|
+
stop = threading.Event()
|
|
183
|
+
handle = self._handle.get()
|
|
184
|
+
if handle is None:
|
|
185
|
+
# Nothing gives it a new value.
|
|
186
|
+
return stop.set
|
|
187
|
+
tp = self._type()
|
|
188
|
+
seen, _ = handle.current()
|
|
189
|
+
|
|
190
|
+
def listen() -> None:
|
|
191
|
+
nonlocal seen
|
|
192
|
+
while not stop.is_set():
|
|
193
|
+
got = handle.wait(seen, _POLL)
|
|
194
|
+
if got is None or stop.is_set():
|
|
195
|
+
continue
|
|
196
|
+
# The revision waited for, not the value now: that may be a
|
|
197
|
+
# later one, which the next wait returns.
|
|
198
|
+
seen, text = got
|
|
199
|
+
try:
|
|
200
|
+
fn(self._value_of(tp, seen, text))
|
|
201
|
+
except Exception:
|
|
202
|
+
_log.exception("config %r: a change listener failed", self._name)
|
|
203
|
+
|
|
204
|
+
threading.Thread(
|
|
205
|
+
target=listen, name=f"encore-config-{self._name}", daemon=True
|
|
206
|
+
).start()
|
|
207
|
+
return stop.set
|
|
208
|
+
|
|
209
|
+
def __repr__(self) -> str:
|
|
210
|
+
return f"{type(self).__name__}({self._name!r})"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Config(_Config[_T]):
|
|
214
|
+
"""A config belonging to the service that declares it.
|
|
215
|
+
|
|
216
|
+
The service holds its own value for it. Must be declared inside a service.
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
checkout = Config[Checkout]("checkout")
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, name: str) -> None:
|
|
223
|
+
"""Declare a service's own config at module scope.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
name: The config's name, an identifier. Instantiate with the type
|
|
227
|
+
argument, such as Config[Checkout]("checkout").
|
|
228
|
+
"""
|
|
229
|
+
super().__init__(name, lambda key: runtime().config(key))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class SharedConfig(_Config[_T]):
|
|
233
|
+
"""A config with one value for the whole app.
|
|
234
|
+
|
|
235
|
+
A service that reads it is recorded as using it. May be declared outside a
|
|
236
|
+
service.
|
|
237
|
+
|
|
238
|
+
Example:
|
|
239
|
+
flags = SharedConfig[Flags]("flags")
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(self, name: str) -> None:
|
|
243
|
+
"""Declare a shared config at module scope.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
name: The config's name, an identifier. Instantiate with the type
|
|
247
|
+
argument, such as SharedConfig[Flags]("flags").
|
|
248
|
+
"""
|
|
249
|
+
super().__init__(name, lambda key: runtime().shared_config(key))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class GlobalConfig(_Config[_T]):
|
|
253
|
+
"""A config with one value for the whole app, available to every service.
|
|
254
|
+
|
|
255
|
+
Reading it is not recorded as using it. May be declared outside a service.
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
limits = GlobalConfig[Limits]("limits")
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(self, name: str) -> None:
|
|
262
|
+
"""Declare a global config at module scope.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
name: The config's name, an identifier. Instantiate with the type
|
|
266
|
+
argument, such as GlobalConfig[Limits]("limits").
|
|
267
|
+
"""
|
|
268
|
+
super().__init__(name, lambda key: runtime().shared_config(key))
|