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.
Files changed (55) hide show
  1. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/PKG-INFO +51 -61
  2. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/README.md +50 -60
  3. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/__init__.py +6 -4
  4. encore_dev-2.0.0a8/encore/config/__init__.py +268 -0
  5. encore_dev-2.0.0a8/encore/config/_build.py +337 -0
  6. encore_dev-2.0.0a8/encore/config/_markers.py +61 -0
  7. {encore_dev-2.0.0a7/encore/meta → encore_dev-2.0.0a8/encore/env}/__init__.py +2 -2
  8. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/_native.pyi +128 -42
  9. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/runtime.py +4 -5
  10. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/startup.py +6 -0
  11. encore_dev-2.0.0a8/encore/metrics/__init__.py +159 -0
  12. encore_dev-2.0.0a8/encore/metrics/_instrument.py +179 -0
  13. encore_dev-2.0.0a8/encore/metrics/_metrics.py +458 -0
  14. encore_dev-2.0.0a8/encore/metrics/_provider.py +276 -0
  15. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/secrets/__init__.py +14 -1
  16. encore_dev-2.0.0a8/encore/services/__init__.py +32 -0
  17. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/postgres/__init__.py +46 -13
  18. encore_dev-2.0.0a8/encore/tracing/__init__.py +27 -0
  19. encore_dev-2.0.0a8/encore/tracing/_provider.py +45 -0
  20. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/pyproject.toml +1 -1
  21. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/pyproject.toml.orig +1 -1
  22. encore_dev-2.0.0a7/encore/metrics/__init__.py +0 -390
  23. encore_dev-2.0.0a7/encore/tracing/__init__.py +0 -19
  24. encore_dev-2.0.0a7/encore/tracing/_provider.py +0 -62
  25. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/cron/__init__.py +0 -0
  26. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/__init__.py +0 -0
  27. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/_loader.py +0 -0
  28. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/internal/lazy.py +0 -0
  29. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/__init__.py +0 -0
  30. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/_console.py +0 -0
  31. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/logs/_runtime.py +0 -0
  32. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/pubsub/__init__.py +0 -0
  33. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/py.typed +0 -0
  34. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/shutdown/__init__.py +0 -0
  35. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/__init__.py +0 -0
  36. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/objects/__init__.py +0 -0
  37. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/__init__.py +0 -0
  38. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_base.py +0 -0
  39. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_responses.py +0 -0
  40. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/_values.py +0 -0
  41. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/bitmap.py +0 -0
  42. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/generic.py +0 -0
  43. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/geo.py +0 -0
  44. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/hash.py +0 -0
  45. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/json.py +0 -0
  46. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/list.py +0 -0
  47. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/pubsub.py +0 -0
  48. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/set.py +0 -0
  49. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/sorted_set.py +0 -0
  50. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/stream.py +0 -0
  51. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/storage/redis/string.py +0 -0
  52. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_context.py +0 -0
  53. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_encode.py +0 -0
  54. {encore_dev-2.0.0a7 → encore_dev-2.0.0a8}/encore/tracing/_span.py +0 -0
  55. {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.0a7
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, access selection currently controls parser permission
77
- inference. Handles still share one runtime pool; per-handle privilege enforcement
78
- and replica routing (including the `read(primary=True)` preference) are pending.
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
- ## Numeric counters and label groups
434
+ ## Metrics
416
435
 
417
- Counters default to unsigned 64-bit integers. Use `Counter[float]` for fractional
418
- increments and `CounterGroup[Labels, float]` for labeled float counters:
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
- operation: str
449
+ route: str
429
450
 
430
451
 
431
- requests = metrics.CounterGroup[Labels]("requests")
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
- requests.with_labels(Labels("checkout")).increment()
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
- This syntax works on Python 3.9 and later. Put numeric type arguments on the
441
- constructor; a variable annotation alone does not select runtime storage.
442
- `with_labels()` returns `Counter[int]` or `Counter[float]` to the type checker.
443
- Existing `Counter(name)` and `CounterGroup[Labels](name)` keep their integer
444
- behavior. Gauge values already use floating-point storage.
445
-
446
- Float increments must be finite and nonnegative. An update that would overflow
447
- the total raises `ValueError` without changing it. Double-precision totals can
448
- incur rounding, including loss of integer precision above `2**53`. Integer and
449
- float counters cannot share a native series. Integer totals still wrap on 64-bit
450
- overflow. Calling `increment()` without a value adds one in either mode.
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
- All metric groups accept dataclass instances as well as mappings, including
453
- TypedDicts. Fields must contain strings, booleans, or finite numbers. Nested
454
- objects are rejected. Numeric labels are rounded down to integer strings and
455
- booleans become `"true"` or `"false"`. Names are normalized to snake_case; `service`
456
- is reserved. The label values are captured when binding, so later mutation does
457
- not change an existing series. `GaugeGroup[Labels]` and `HistogramGroup[Labels]`
458
- also provide typed label arguments.
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
- ## Histogram metrics
469
+ ## OpenTelemetry
461
470
 
462
- Declare histograms at module scope to record distributions such as request
463
- duration or payload size. Record one observation for each measurement:
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 typing import TypedDict
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
- latency = metrics.Histogram("request_duration", unit="seconds")
476
- sizes = metrics.HistogramGroup[Labels]("response_size", unit="bytes")
477
-
478
- latency.observe(0.025)
479
- sizes.with_labels({"route": "/orders"}).observe(512)
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, access selection currently controls parser permission
64
- inference. Handles still share one runtime pool; per-handle privilege enforcement
65
- and replica routing (including the `read(primary=True)` preference) are pending.
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
- ## Numeric counters and label groups
421
+ ## Metrics
403
422
 
404
- Counters default to unsigned 64-bit integers. Use `Counter[float]` for fractional
405
- increments and `CounterGroup[Labels, float]` for labeled float counters:
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
- operation: str
436
+ route: str
416
437
 
417
438
 
418
- requests = metrics.CounterGroup[Labels]("requests")
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
- requests.with_labels(Labels("checkout")).increment()
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
- This syntax works on Python 3.9 and later. Put numeric type arguments on the
428
- constructor; a variable annotation alone does not select runtime storage.
429
- `with_labels()` returns `Counter[int]` or `Counter[float]` to the type checker.
430
- Existing `Counter(name)` and `CounterGroup[Labels](name)` keep their integer
431
- behavior. Gauge values already use floating-point storage.
432
-
433
- Float increments must be finite and nonnegative. An update that would overflow
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
- All metric groups accept dataclass instances as well as mappings, including
440
- TypedDicts. Fields must contain strings, booleans, or finite numbers. Nested
441
- objects are rejected. Numeric labels are rounded down to integer strings and
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
- ## Histogram metrics
456
+ ## OpenTelemetry
448
457
 
449
- Declare histograms at module scope to record distributions such as request
450
- duration or payload size. Record one observation for each measurement:
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 typing import TypedDict
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
- latency = metrics.Histogram("request_duration", unit="seconds")
463
- sizes = metrics.HistogramGroup[Labels]("response_size", unit="bytes")
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, meta, metrics, pubsub, secrets, shutdown, storage
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 .meta import app_meta
28
+ from .env import app_meta
29
29
 
30
30
  __all__ = [
31
+ "config",
31
32
  "storage",
32
33
  "pubsub",
33
34
  "cron",
34
- "meta",
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))