encore-dev 2.0.0a7__py3-none-any.whl

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 (44) hide show
  1. encore/__init__.py +43 -0
  2. encore/cron/__init__.py +50 -0
  3. encore/internal/__init__.py +1 -0
  4. encore/internal/_loader.py +79 -0
  5. encore/internal/_native.pyi +734 -0
  6. encore/internal/lazy.py +35 -0
  7. encore/internal/runtime.py +133 -0
  8. encore/internal/startup.py +24 -0
  9. encore/logs/__init__.py +19 -0
  10. encore/logs/_console.py +155 -0
  11. encore/logs/_runtime.py +56 -0
  12. encore/meta/__init__.py +228 -0
  13. encore/metrics/__init__.py +390 -0
  14. encore/pubsub/__init__.py +425 -0
  15. encore/py.typed +0 -0
  16. encore/secrets/__init__.py +139 -0
  17. encore/shutdown/__init__.py +164 -0
  18. encore/storage/__init__.py +5 -0
  19. encore/storage/objects/__init__.py +396 -0
  20. encore/storage/postgres/__init__.py +737 -0
  21. encore/storage/redis/__init__.py +200 -0
  22. encore/storage/redis/_base.py +42 -0
  23. encore/storage/redis/_responses.py +150 -0
  24. encore/storage/redis/_values.py +189 -0
  25. encore/storage/redis/bitmap.py +142 -0
  26. encore/storage/redis/generic.py +367 -0
  27. encore/storage/redis/geo.py +508 -0
  28. encore/storage/redis/hash.py +504 -0
  29. encore/storage/redis/json.py +614 -0
  30. encore/storage/redis/list.py +511 -0
  31. encore/storage/redis/pubsub.py +276 -0
  32. encore/storage/redis/set.py +570 -0
  33. encore/storage/redis/sorted_set.py +753 -0
  34. encore/storage/redis/stream.py +773 -0
  35. encore/storage/redis/string.py +654 -0
  36. encore/tracing/__init__.py +19 -0
  37. encore/tracing/_context.py +27 -0
  38. encore/tracing/_encode.py +351 -0
  39. encore/tracing/_provider.py +62 -0
  40. encore/tracing/_span.py +226 -0
  41. encore/tracing/_tracer.py +93 -0
  42. encore_dev-2.0.0a7.dist-info/METADATA +791 -0
  43. encore_dev-2.0.0a7.dist-info/RECORD +44 -0
  44. encore_dev-2.0.0a7.dist-info/WHEEL +4 -0
@@ -0,0 +1,35 @@
1
+ """Thread-safe initialization for runtime resource handles."""
2
+
3
+ from threading import Lock
4
+ from typing import Callable, Generic, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class Lazy(Generic[T]):
10
+ """A thread-safe cache of the first successful factory result.
11
+
12
+ None is cached like any other result. Failed initialization can be retried.
13
+ """
14
+
15
+ def __init__(self, factory: Callable[[], T]) -> None:
16
+ """Initialize an unresolved value.
17
+
18
+ Args:
19
+ factory: A callable invoked on first access and retried if it raises.
20
+ """
21
+ self._factory = factory
22
+ self._value: tuple[T] | None = None
23
+ self._lock = Lock()
24
+
25
+ def get(self) -> T:
26
+ # The cached tuple is published only after initialization and never replaced.
27
+ """Return the cached value, initializing it once under a lock if needed."""
28
+ if (value := self._value) is not None:
29
+ return value[0]
30
+
31
+ with self._lock:
32
+ # Another caller may have initialized the value while we waited.
33
+ if self._value is None:
34
+ self._value = (self._factory(),)
35
+ return self._value[0]
@@ -0,0 +1,133 @@
1
+ """The process-wide runtime singleton over the native extension.
2
+
3
+ This is the Python analogue of ``sdk/ts/infra/internal/runtime/mod.ts``: the
4
+ native module wraps ``encore_sdk_core`` over PyO3, and the rest of the ``encore``
5
+ package builds an ergonomic API on top of the runtime handle.
6
+
7
+ The runtime is initialized synchronously when this module is imported, matching
8
+ the TypeScript SDK. Encore must supply runtime configuration before application
9
+ imports; initialization errors fail startup before requests are served.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import atexit
15
+ import json
16
+ import signal
17
+ import sys
18
+ import threading
19
+
20
+ from ._loader import _native, _runtime_relative_path # noqa: F401
21
+
22
+ # The native error type, re-exported for callers that want to catch it.
23
+ EncoreError = _native.EncoreError
24
+
25
+ from ..logs._runtime import handle_line as _handle_runtime_line # noqa: E402
26
+
27
+ _native.set_runtime_log_handler(_handle_runtime_line)
28
+
29
+ #: How long the runtime's queued lines may take to reach ``logging`` at exit.
30
+ _RUNTIME_LOG_FLUSH = 1.0
31
+
32
+
33
+ def _flush_runtime_log() -> None:
34
+ """Hand the runtime's queued lines to ``logging`` before exit."""
35
+ lost = _native.flush_runtime_log(_RUNTIME_LOG_FLUSH)
36
+ if lost:
37
+ print(f"encore: lost {lost} of the runtime's log lines", file=sys.stderr)
38
+
39
+
40
+ # Registered first, so it runs after the exit handlers registered later.
41
+ atexit.register(_flush_runtime_log)
42
+
43
+ _runtime = _native.Runtime()
44
+
45
+ from ..tracing._provider import install as _install_tracing # noqa: E402
46
+
47
+ _telemetry = _runtime.telemetry()
48
+ _install_tracing(_telemetry)
49
+
50
+
51
+ #: The outcome of the shutdown at exit, once run: whether it completed in time.
52
+ _shutdown_ok: bool | None = None
53
+
54
+
55
+ def _shutdown() -> bool:
56
+ """Drain infrastructure and flush telemetry once.
57
+
58
+ Wait for an existing application-initiated shutdown if one has begun.
59
+
60
+ Returns:
61
+ Whether shutdown completed within its deadline.
62
+ """
63
+ global _shutdown_ok
64
+ if _shutdown_ok is not None:
65
+ return _shutdown_ok
66
+ import asyncio
67
+
68
+ from .. import shutdown
69
+
70
+ if threading.current_thread() is threading.main_thread():
71
+ # A repeated SIGTERM must not interrupt the flush.
72
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
73
+ shutdown.begin()
74
+ result: list[bool] = []
75
+
76
+ def wait() -> None:
77
+ try:
78
+ done = asyncio.run(shutdown.completed())
79
+ result.append(done is shutdown.ShutdownResult.SUCCESS)
80
+ except Exception as exc: # noqa: BLE001
81
+ print(f"encore: shutdown failed: {exc}", file=sys.stderr)
82
+
83
+ # Off this thread, which may be running an event loop.
84
+ waiter = threading.Thread(target=wait, name="encore-shutdown")
85
+ waiter.start()
86
+ waiter.join()
87
+ _shutdown_ok = bool(result and result[0])
88
+ return _shutdown_ok
89
+
90
+
91
+ def _shutdown_at_exit() -> None:
92
+ _shutdown()
93
+
94
+
95
+ def _exit_on_sigterm(signum: int, frame: object) -> None:
96
+ """Exit with 0 if shutdown completes in time, or 1 otherwise."""
97
+ raise SystemExit(0 if _shutdown() else 1)
98
+
99
+
100
+ def _testing() -> bool:
101
+ """Whether the process runs tests, where the test runner owns SIGTERM."""
102
+ meta = json.loads(_runtime.app_meta_json())
103
+ return (meta.get("environment") or {}).get("type") == "test"
104
+
105
+
106
+ atexit.register(_shutdown_at_exit)
107
+ # SIGTERM's default action skips atexit. Servers that handle it themselves
108
+ # replace this handler, and re-raise the signal after restoring it.
109
+ if (
110
+ threading.current_thread() is threading.main_thread()
111
+ and signal.getsignal(signal.SIGTERM) is signal.SIG_DFL
112
+ and not _testing()
113
+ ):
114
+ signal.signal(signal.SIGTERM, _exit_on_sigterm)
115
+
116
+
117
+ def runtime() -> _native.Runtime:
118
+ """Return the process-wide core runtime.
119
+
120
+ The runtime is initialized from the ambient ENCORE_* environment when
121
+ this module is imported. Resources are resolved by name using its config.
122
+ """
123
+ return _runtime
124
+
125
+
126
+ def run_forever() -> None:
127
+ """Start draining infrastructure (subscriptions, metrics) and keep serving."""
128
+ runtime().run_forever()
129
+
130
+
131
+ def version() -> str:
132
+ """The version of the Encore runtime in use."""
133
+ return _native.Runtime.version()
@@ -0,0 +1,24 @@
1
+ """Application-loop startup for registered Encore resources."""
2
+
3
+ from .. import pubsub
4
+
5
+
6
+ async def start() -> None:
7
+ """The single Encore startup entrypoint for an application's asyncio loop.
8
+
9
+ Start all registered subscriptions and return. Import resource declarations
10
+ before calling this from your async main or framework startup hook.
11
+ Repeated calls on the same loop reuse consumers; later calls also start newly
12
+ imported subscriptions. This does not own the loop or install signal handlers.
13
+ """
14
+ subscriptions = list(pubsub._subscriptions)
15
+
16
+ # First loop, verify that all subscriptions are available
17
+ for subscription in subscriptions:
18
+ subscription._check_loop()
19
+ subscription._handle.get()
20
+
21
+ # Start all subscriptions
22
+ # This way no partial startup is possible
23
+ for subscription in subscriptions:
24
+ subscription._start()
@@ -0,0 +1,19 @@
1
+ """Log records in the Encore log format.
2
+
3
+ :class:`ConsoleHandler` is a ``logging`` handler writing each record as one
4
+ line in the Encore log format to stderr, which Encore's log collector reads;
5
+ put it on the root logger::
6
+
7
+ import logging
8
+
9
+ from encore.logs import ConsoleHandler
10
+
11
+ logging.basicConfig(level=logging.INFO, handlers=[ConsoleHandler()])
12
+
13
+ A record handled while a span is current carries its trace and span ids.
14
+ The runtime's own lines log under the ``encore.runtime`` logger.
15
+ """
16
+
17
+ from ._console import ConsoleHandler
18
+
19
+ __all__ = ["ConsoleHandler"]
@@ -0,0 +1,155 @@
1
+ """The ``logging`` handler printing the Encore log format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import math
8
+ import traceback
9
+ from typing import Any, Optional, TextIO
10
+
11
+ from opentelemetry import trace
12
+
13
+ from ..internal._loader import _native
14
+
15
+ #: The record attribute holding the ``SpanContext`` a record belongs to, over
16
+ #: the current span's.
17
+ SPAN_CONTEXT_ATTR = "_encore_span_context"
18
+
19
+ #: The record attribute holding the runtime module a line of the runtime's
20
+ #: own comes from.
21
+ RUNTIME_TARGET_ATTR = "_encore_runtime_target"
22
+
23
+ #: Attributes every ``LogRecord`` has; the rest come from ``extra``.
24
+ _STANDARD = frozenset(
25
+ logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
26
+ | {"message", "asctime", "taskName", SPAN_CONTEXT_ATTR, RUNTIME_TARGET_ATTR}
27
+ )
28
+
29
+
30
+ class ConsoleHandler(logging.StreamHandler):
31
+ """A logging handler that writes one Encore-formatted line per record.
32
+
33
+ Records are written to stream, or sys.stderr by default. Each line carries
34
+ the time, level,
35
+ message, logger name, the caller as ``code.*``, ``exc_info`` as
36
+ ``exception.*``, the ``extra`` attributes, and the ids of the span current
37
+ when the record is handled, with its sampled flag. The SDK core encodes
38
+ it; it needs the native library, not the runtime. Formatters are not
39
+ used.
40
+
41
+ An attribute named like a reserved key (``message``, ``trace_id``, …)
42
+ with a value valid for it is that field, in place of the record's; one
43
+ with an invalid value, or named like a key of the record's caller or
44
+ exception, is written as ``raw.<key>``.
45
+ """
46
+
47
+ def __init__(
48
+ self, stream: Optional[TextIO] = None, level: int | str = logging.NOTSET
49
+ ) -> None:
50
+ """Initialize an Encore console log handler.
51
+
52
+ Args:
53
+ stream: The text stream to write to, or None to use sys.stderr.
54
+ level: The minimum logging level as a number or logging level name.
55
+ """
56
+ super().__init__(stream)
57
+ self.setLevel(level)
58
+
59
+ def format(self, record: logging.LogRecord) -> str:
60
+ """Return the record as one Encore log line without a trailing newline.
61
+
62
+ Custom logging formatters are ignored.
63
+
64
+ Args:
65
+ record: The log record to encode.
66
+ """
67
+ return encode(record)
68
+
69
+
70
+ def encode(record: logging.LogRecord) -> str:
71
+ """Return a record as one Encore log line without a trailing newline.
72
+
73
+ Args:
74
+ record: The record to encode. An explicit span context takes precedence
75
+ over the current OpenTelemetry span.
76
+ """
77
+ sc = getattr(record, SPAN_CONTEXT_ATTR, None)
78
+ if sc is None:
79
+ sc = trace.get_current_span().get_span_context()
80
+ span = None
81
+ if sc.is_valid:
82
+ span = (
83
+ sc.trace_id.to_bytes(16, "big")
84
+ + sc.span_id.to_bytes(8, "big")
85
+ + bytes((sc.trace_flags,))
86
+ )
87
+ exception_type = exception_message = exception_stacktrace = None
88
+ exc = record.exc_info
89
+ if exc and exc[0] is not None:
90
+ exception_type = _type_name(exc[0])
91
+ exception_message = str(exc[1])
92
+ exception_stacktrace = "".join(traceback.format_exception(*exc)).rstrip("\n")
93
+ return _native.encode_log(
94
+ _severity(record.levelno),
95
+ round(record.created * 1_000_000) * 1000,
96
+ record.getMessage(),
97
+ logger=record.name or None,
98
+ span_ctx=span,
99
+ exception_type=exception_type,
100
+ exception_message=exception_message,
101
+ exception_stacktrace=exception_stacktrace,
102
+ code_function=record.funcName or None,
103
+ code_file=record.pathname or None,
104
+ code_line=record.lineno or 0,
105
+ runtime_target=getattr(record, RUNTIME_TARGET_ATTR, None),
106
+ attrs_json=json.dumps(
107
+ _attributes(record), separators=(",", ":"), allow_nan=False
108
+ ),
109
+ )
110
+
111
+
112
+ def _severity(levelno: int) -> int:
113
+ """The OpenTelemetry severity number of the range ``levelno`` is in."""
114
+ if levelno < logging.DEBUG:
115
+ return 1
116
+ if levelno < logging.INFO:
117
+ return 5
118
+ if levelno < logging.WARNING:
119
+ return 9
120
+ if levelno < logging.ERROR:
121
+ return 13
122
+ if levelno < logging.CRITICAL:
123
+ return 17
124
+ return 21
125
+
126
+
127
+ def _attributes(record: logging.LogRecord) -> dict[str, Any]:
128
+ """The ``extra`` attributes."""
129
+ return {
130
+ key: _json(value)
131
+ for key, value in record.__dict__.items()
132
+ if key not in _STANDARD
133
+ }
134
+
135
+
136
+ def _type_name(t: type) -> str:
137
+ if t.__module__ == "builtins":
138
+ return t.__qualname__
139
+ return f"{t.__module__}.{t.__qualname__}"
140
+
141
+
142
+ def _json(v: object) -> Any:
143
+ """Return JSON-compatible values, converting unsupported values to strings.
144
+
145
+ Non-finite floats are also converted to strings.
146
+ """
147
+ if v is None or isinstance(v, (str, bool, int)):
148
+ return v
149
+ if isinstance(v, float):
150
+ return v if math.isfinite(v) else str(v)
151
+ if isinstance(v, (list, tuple)):
152
+ return [_json(x) for x in v]
153
+ if isinstance(v, dict):
154
+ return {str(k): _json(x) for k, x in v.items()}
155
+ return str(v)
@@ -0,0 +1,56 @@
1
+ """The runtime's own log lines as ``logging`` records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from opentelemetry.trace import SpanContext, TraceFlags
10
+
11
+ from ..internal._loader import _native
12
+ from ._console import RUNTIME_TARGET_ATTR, SPAN_CONTEXT_ATTR
13
+
14
+ #: The runtime's lines log under ``encore.runtime``, their target an
15
+ #: attribute.
16
+ LOGGER: str = _native.RUNTIME_LOGGER
17
+
18
+ #: The runtime's TRACE; ``logging`` has none.
19
+ TRACE = 5
20
+
21
+ # The runtime's levels: 1 ERROR to 5 TRACE.
22
+ _LEVELS = (logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG, TRACE)
23
+
24
+
25
+ def handle_line(
26
+ level: int,
27
+ target: str,
28
+ message: str,
29
+ fields_json: str,
30
+ span_ctx: Optional[bytes],
31
+ ) -> None:
32
+ """Log one runtime line from the native log-handler callback.
33
+
34
+ Records use the encore.runtime logger. The target is stored in the
35
+ RUNTIME_TARGET_ATTR record attribute and fields become record attributes.
36
+ Fields colliding with LogRecord attributes use a raw.<name> key. The
37
+ 25-byte span_ctx supplies the record's span.
38
+ """
39
+ levelno = _LEVELS[min(max(level, 1), 5) - 1]
40
+ logger = logging.getLogger(LOGGER)
41
+ if not logger.isEnabledFor(levelno):
42
+ return
43
+ record = logger.makeRecord(LOGGER, levelno, "", 0, message, (), None)
44
+ for key, value in json.loads(fields_json).items():
45
+ while key in record.__dict__ or key in ("message", "asctime"):
46
+ key = "raw." + key
47
+ record.__dict__[key] = value
48
+ record.__dict__[RUNTIME_TARGET_ATTR] = target
49
+ if span_ctx:
50
+ record.__dict__[SPAN_CONTEXT_ATTR] = SpanContext(
51
+ int.from_bytes(span_ctx[:16], "big"),
52
+ int.from_bytes(span_ctx[16:24], "big"),
53
+ is_remote=False,
54
+ trace_flags=TraceFlags(span_ctx[24]),
55
+ )
56
+ logger.handle(record)
@@ -0,0 +1,228 @@
1
+ """Metadata for the running application and its API gateways.
2
+
3
+ Typical usage example:
4
+
5
+ from encore import meta
6
+
7
+ md = meta.app_meta()
8
+ if md.environment.type == "production":
9
+ ...
10
+
11
+ # An app can declare several gateways, so look one up by name.
12
+ gw = md.gateways.get("api")
13
+ if gw is not None:
14
+ print("api is at", gw.base_url)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timezone
23
+ from typing import Dict, List, Optional
24
+
25
+ from ..internal.runtime import runtime
26
+
27
+ __all__ = [
28
+ "app_meta",
29
+ "AppMeta",
30
+ "GatewayMeta",
31
+ "EnvironmentMeta",
32
+ "BuildMeta",
33
+ "DeployMeta",
34
+ "HostedService",
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class GatewayMeta:
40
+ """An API gateway serving the application.
41
+
42
+ Attributes:
43
+ name: The name of the gateway, as declared by the application.
44
+ base_url: The base URL which can be used to call the API of this running
45
+ application through this gateway.
46
+
47
+ For local development it is ``http://localhost:<port>``, typically
48
+ ``http://localhost:4000``.
49
+
50
+ If a custom domain is used for this environment it is returned here,
51
+ but note that changes only take effect at the time of deployment
52
+ while custom domains can be updated at any time.
53
+ """
54
+
55
+ name: str
56
+
57
+ base_url: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class EnvironmentMeta:
62
+ """The environment the application is running in.
63
+
64
+ Attributes:
65
+ name: The name of the environment. For local development it is
66
+ ``"local"``.
67
+ type: The type of environment: ``"production"``, ``"development"``,
68
+ ``"ephemeral"`` or ``"test"``. For local development it is
69
+ ``"development"``.
70
+ cloud: The cloud the environment runs on: ``"aws"``, ``"gcp"``,
71
+ ``"azure"``, ``"encore"`` or ``"local"``. For local development it
72
+ is ``"local"``.
73
+ """
74
+
75
+ name: str
76
+
77
+ type: str
78
+
79
+ cloud: str
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class BuildMeta:
84
+ """The build that formed the running application.
85
+
86
+ Attributes:
87
+ revision: The VCS revision the deployed application was built from.
88
+ """
89
+
90
+ revision: str
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class HostedService:
95
+ """A service hosted by this deployment.
96
+
97
+ Attributes:
98
+ name: The name of the service.
99
+ """
100
+
101
+ name: str
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class DeployMeta:
106
+ """This deployment of the application.
107
+
108
+ Attributes:
109
+ id: The unique id of the deployment. Generated by the Encore Platform.
110
+ time: The time the Encore Platform deployed this build to the
111
+ environment.
112
+ hosted_services: The services hosted by this deployment.
113
+ """
114
+
115
+ id: str
116
+
117
+ time: datetime
118
+
119
+ hosted_services: List[HostedService] = field(default_factory=list)
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class AppMeta:
124
+ """Metadata about the running Encore application.
125
+
126
+ Attributes:
127
+ app_id: The Encore application ID. Empty if the application is not
128
+ linked to the Encore platform; run ``encore app link`` to link it.
129
+ app_slug: The application slug — the human-readable identifier shown in
130
+ the Encore Cloud dashboard and used by the CLI. Empty if the
131
+ application is not linked to the Encore platform.
132
+
133
+ Distinct from :attr:`app_id`: the id is opaque and stable, the slug
134
+ is readable and may be renamed. Locally the two are the same.
135
+ gateways: The API gateways that serve this application, keyed by gateway
136
+ name.
137
+
138
+ An application can declare several gateways, so there is no single
139
+ base URL for the API — look up the gateway you mean by name. A
140
+ process that serves no gateway (a worker) reports an empty mapping.
141
+ environment: The environment the app is running in.
142
+ build: The build the running binary came from.
143
+ deploy: This deployment of the binary.
144
+ """
145
+
146
+ app_id: str
147
+
148
+ app_slug: str
149
+
150
+ gateways: Dict[str, GatewayMeta]
151
+
152
+ environment: EnvironmentMeta
153
+
154
+ build: BuildMeta
155
+
156
+ deploy: DeployMeta
157
+
158
+
159
+ _cached: Optional[AppMeta] = None
160
+
161
+
162
+ def app_meta() -> AppMeta:
163
+ """Return metadata about the running Encore application.
164
+
165
+ The metadata is computed on first use and the same object is returned by every
166
+ subsequent call.
167
+ """
168
+ global _cached
169
+ if _cached is None:
170
+ _cached = _decode(json.loads(runtime().app_meta_json()))
171
+ return _cached
172
+
173
+
174
+ def _decode(raw: dict) -> AppMeta:
175
+ env = raw.get("environment") or {}
176
+ build = raw.get("build") or {}
177
+ deploy = raw.get("deploy") or {}
178
+
179
+ return AppMeta(
180
+ app_id=raw.get("app_id", ""),
181
+ app_slug=raw.get("app_slug", ""),
182
+ gateways={
183
+ name: GatewayMeta(
184
+ name=gw.get("name", name), base_url=gw.get("base_url", "")
185
+ )
186
+ for name, gw in (raw.get("gateways") or {}).items()
187
+ },
188
+ environment=EnvironmentMeta(
189
+ name=env.get("name", ""),
190
+ type=env.get("type", "development"),
191
+ cloud=env.get("cloud", "local"),
192
+ ),
193
+ build=BuildMeta(revision=build.get("revision", "")),
194
+ deploy=DeployMeta(
195
+ id=deploy.get("id", ""),
196
+ time=_parse_time(deploy.get("deploy_time")),
197
+ hosted_services=[
198
+ HostedService(name=svc.get("name", ""))
199
+ for svc in (deploy.get("hosted_services") or [])
200
+ ],
201
+ ),
202
+ )
203
+
204
+
205
+ # Matches a fractional-seconds group of any precision, so it can be trimmed to
206
+ # the six digits datetime supports.
207
+ _FRACTION = re.compile(r"\.(\d+)")
208
+
209
+
210
+ def _parse_time(value: Optional[str]) -> datetime:
211
+ """Parse the core's RFC 3339 timestamp.
212
+
213
+ ``datetime.fromisoformat`` is only lenient enough on Python 3.11+, and the
214
+ core emits nanosecond precision and a ``Z`` suffix — both of which older
215
+ versions reject. Normalize to what every supported version accepts.
216
+ """
217
+ if not value:
218
+ return datetime.fromtimestamp(0, tz=timezone.utc)
219
+
220
+ normalized = value
221
+ if normalized.endswith(("Z", "z")):
222
+ normalized = normalized[:-1] + "+00:00"
223
+ normalized = _FRACTION.sub(lambda m: "." + m.group(1)[:6].ljust(6, "0"), normalized)
224
+
225
+ try:
226
+ return datetime.fromisoformat(normalized)
227
+ except ValueError:
228
+ return datetime.fromtimestamp(0, tz=timezone.utc)