logfire-fleet-client 0.2.0__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.
@@ -0,0 +1,21 @@
1
+ # Copyright (c) 2026 Pydantic Services Inc.
2
+ # SPDX-License-Identifier: MIT
3
+ """An OpAMP client for Python (asyncio).
4
+
5
+ Everything OpAMP (what to send and when, backoff, heartbeats, full reports, instance UIDs,
6
+ agent_disconnect, and the requests and responses themselves: headers, framing, Retry-After, size
7
+ limits) is the shared OpAMP client core, run natively (logfire_fleet_client._native). By default the
8
+ core speaks HTTP and WebSocket itself too (core mode), over asyncio's TCP connections and TLS
9
+ (``_streams.py``); with ``Config(mode="platform")`` this package carries out the core's requests
10
+ with aiohttp instead. Either way it feeds back what happened, so it behaves exactly like the
11
+ other languages' clients on the same core.
12
+ """
13
+
14
+ from ._client import Client, Config, Notification, Status
15
+ from ._core import CoreError
16
+ from ._executor import save_state
17
+ from ._transport import TLS, Proxy
18
+ from ._proto.opamp.v1 import anyvalue_pb2, opamp_pb2
19
+
20
+ __all__ = ["TLS", "Client", "Config", "CoreError", "Notification", "Proxy", "Status", "anyvalue_pb2", "opamp_pb2",
21
+ "save_state"]
@@ -0,0 +1,592 @@
1
+ # Copyright (c) 2026 Pydantic Services Inc.
2
+ # SPDX-License-Identifier: MIT
3
+ """The client: feeds the core, carries out its effects, runs its timer."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import os
9
+ import ssl
10
+ import threading
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field, fields
15
+ from typing import Any
16
+
17
+ import aiohttp
18
+
19
+ from ._core import Core
20
+ from ._executor import Executor, clear_downloads, read_state
21
+ from ._files import REAL, AfterClear, Files, Journal
22
+ from ._proto.opamp.core.v1 import core_pb2 as corepb
23
+ from ._proto.opamp.v1 import opamp_pb2 as pb
24
+ from ._streams import Streams, proxy_choice
25
+ from ._transport import TLS, Proxy, Transport, redacted_headers, redacted_url
26
+
27
+
28
+ def _u64(n: int) -> int:
29
+ """A time or size for the core: a negative one reaches it as its two's complement, which the core
30
+ refuses at start, naming the field and the number meant (as in every SDK)."""
31
+ return n & 0xFFFF_FFFF_FFFF_FFFF
32
+
33
+
34
+ @dataclass
35
+ class Config:
36
+ """The client's settings."""
37
+
38
+ #: The server URL: http(s)://... for plain HTTP, ws(s)://... for WebSocket.
39
+ endpoint: str
40
+ #: The instance UID to start with (16 bytes). The server may assign another. None: the core
41
+ #: makes one (a UUIDv7, from the clock and the operating system's randomness), and with a
42
+ #: state_file keeps it across restarts. ``Client.instance_uid`` says which is in use.
43
+ instance_uid: bytes | None = None
44
+ #: Capabilities the agent supports (AgentCapabilities bits), fixed for the run. ReportsStatus is
45
+ #: always added, and the core adds the bits of what it does itself (packages, connection
46
+ #: settings). Declare ReportsHeartbeat for heartbeats, and the Reports* bit of each status you
47
+ #: set: a status whose bit isn't declared isn't sent.
48
+ capabilities: int = 0
49
+ #: Heartbeat (WebSocket) or polling (HTTP) interval until the server offers one; 0: 30 s.
50
+ heartbeat_interval_ms: int = 0
51
+ #: Sent with every request or upgrade (Authorization, say). One that can't be sent as given (a
52
+ #: name that isn't a token, a control character in a value or whitespace at its ends, one of
53
+ #: the headers the HTTP stack writes) fails each request, with why, in both modes.
54
+ headers: dict[str, str] = field(default_factory=dict)
55
+ #: Plain HTTP request timeout; 0: 30 s.
56
+ request_timeout_ms: int = 0
57
+ #: The largest message sent, encoded; 0: 64 MiB, and at least 1 KiB. A status that doesn't fit
58
+ #: is left out (``status_too_large``).
59
+ #: covers: OPAMP-SIZE-02, OPAMP-SIZE-08
60
+ max_send_bytes: int = 0
61
+ #: The largest response or message accepted; 0: 64 MiB.
62
+ #: covers: OPAMP-SIZE-02, OPAMP-SIZE-08
63
+ max_receive_bytes: int = 0
64
+ #: Where the client keeps state across restarts; None: nowhere. It holds secrets (credentials
65
+ #: the server issued, private keys of telemetry settings' certificates), so it is written
66
+ #: readable by the agent's user only (mode 0600). On Windows it takes its directory's
67
+ #: permissions: keep it on a local path only the agent's user can read.
68
+ state_file: str | None = None
69
+ #: Take offers of new connection settings from the server (docs/connection-settings.md).
70
+ #: Offers are refused without a state_file: a restart would lose them.
71
+ accept_connection_settings: bool = False
72
+ #: How long offered settings have to prove themselves; 0: 60 s.
73
+ verify_timeout_ms: int = 0
74
+ #: Take packages from the server (docs/packages.md): the client downloads them to
75
+ #: ``state_file + ".downloads"`` and the application installs them (``install_package``,
76
+ #: ``remove_package``). Only with a state_file: a restart would forget what the agent has.
77
+ accept_packages: bool = False
78
+ #: Ask the server to assign the instance UID (the RequestInstanceUid flag,
79
+ #: docs/instance-uid.md). The UID the client starts with (made up, or instance_uid) is the
80
+ #: temporary value until the server assigns one. With a state_file the assigned UID is kept,
81
+ #: and a restart doesn't ask again; without one every start asks again, and the server assigns
82
+ #: a new UID each time: the agent is a new agent after every restart.
83
+ #: covers: OPAMP-UID-04
84
+ request_instance_uid: bool = False
85
+ #: The largest package file downloaded; 0: 1 GiB.
86
+ max_package_bytes: int = 0
87
+ #: How long a download may take; 0: 10 minutes.
88
+ download_timeout_ms: int = 0
89
+ #: How long stop may take (the goodbye, and any closing handshake); 0: 5 s.
90
+ stop_timeout_ms: int = 0
91
+ #: How long a WebSocket connection may take to open; 0: 30 s.
92
+ connect_timeout_ms: int = 0
93
+ #: TLS for https:// and wss:// endpoints, https:// proxies and package downloads, in both
94
+ #: modes: a TLS (the CAs to trust, a client certificate, versions), or an ssl.SSLContext used
95
+ #: as it is. None: the system's CAs, TLS 1.2 or later.
96
+ #: covers: RULE-TLS-01, RULE-TLS-02
97
+ tls: TLS | ssl.SSLContext | None = None
98
+ #: The proxy to reach the server and download packages through: a Proxy, or just its URL
99
+ #: (``http://``, ``https://``, ``socks5://`` or ``socks5h://``, with ``user:password@`` if
100
+ #: it needs them; through SOCKS5 the proxy resolves the server's name, unless the Proxy has
101
+ #: resolve_locally). None: the environment's, read at start (HTTP_PROXY for http:// and
102
+ #: ws://, HTTPS_PROXY for https:// and wss://, ALL_PROXY for either when its own is unset,
103
+ #: NO_PROXY: names, addresses and CIDR networks), by the rule every SDK's core mode shares; in
104
+ #: platform mode as urllib reads them (lower-case names first, name:port entries too).
105
+ #: Proxy.DIRECT: none at all.
106
+ #: covers: RULE-PROXY-01
107
+ proxy: Proxy | str | None = None
108
+ #: Who speaks HTTP and WebSocket. ``"core"`` (the default): the shared core itself,
109
+ #: over asyncio's TCP connections and TLS with the settings above; Python
110
+ #: only opens connections, starts TLS, picks the proxy and writes package files. ``"platform"``:
111
+ #: aiohttp, for an application that needs Python's own stack (urllib's reading of the
112
+ #: environment's proxies, say).
113
+ mode: str = "core"
114
+ #: Core mode: package files go from the socket to the file without passing through the core
115
+ #: (where the response says how long the body is). It is core mode's: False in platform mode is
116
+ #: an error.
117
+ splice_downloads: bool = True
118
+
119
+ def __repr__(self) -> str:
120
+ # Without the secrets: the headers' values (Authorization, say) and a proxy URL's password.
121
+ def shown(name: str, value: object) -> object:
122
+ if name == "headers":
123
+ return redacted_headers(self.headers)
124
+ if name == "proxy" and isinstance(value, str):
125
+ return redacted_url(value)
126
+ return value
127
+
128
+ parts = (f"{f.name}={shown(f.name, getattr(self, f.name))!r}" for f in fields(self))
129
+ return f"Config({', '.join(parts)})"
130
+
131
+
132
+ @dataclass
133
+ class Status:
134
+ """The agent's status as the client starts. The first message reports it in full."""
135
+
136
+ description: pb.AgentDescription | None = None
137
+ health: pb.ComponentHealth | None = None
138
+ effective_config: pb.EffectiveConfig | None = None
139
+ remote_config_status: pb.RemoteConfigStatus | None = None
140
+ # The components the agent has (leave `hash` empty: the core computes it). The server gets the
141
+ # hash, and the components in full when it asks.
142
+ available_components: pb.AvailableComponents | None = None
143
+ # The custom capabilities the agent supports: custom messages for them reach the application,
144
+ # and it may send them.
145
+ custom_capabilities: pb.CustomCapabilities | None = None
146
+
147
+
148
+ @dataclass
149
+ class Notification:
150
+ """What the application is told. `kind` says which; the other fields go with it:
151
+
152
+ - ``connected``: a WebSocket connected, or an HTTP request succeeded (the first, or the first
153
+ after failures)
154
+ - ``connect_failed``: ``status`` (an HTTP status, if there was one), ``detail``
155
+ - ``error_response``: ``error_type`` (ServerErrorResponseType), ``message``
156
+ - ``remote_config``: ``remote_config`` (AgentRemoteConfig): answer with set_remote_config_status
157
+ - ``custom_message``: ``custom_message``, from the server, for a declared custom capability
158
+ - ``server_custom_capabilities``: ``custom_capabilities`` (the server's, a list of strings)
159
+ - ``custom_message_not_sent``: ``custom_message``, ``detail`` (why it won't be sent)
160
+ - ``connection_settings``: ``connection_settings`` (ConnectionSettingsOffers: own telemetry and
161
+ other connection settings, all of them): answer with set_connection_settings_status
162
+ - ``install_package``: ``package_name``, ``package`` (PackageAvailable: version, hash, type, the
163
+ file's signature), ``path`` (the downloaded file): install it (checking the signature is the
164
+ application's job), then answer with package_installed; the client deletes the file after
165
+ - ``remove_package``: ``package_name``, no longer offered: remove it, then answer with
166
+ package_removed
167
+ - ``command``: ``command`` (ServerToAgentCommand)
168
+ - ``instance_uid_changed``: ``instance_uid``
169
+ - ``heartbeat_interval``: ``heartbeat_interval_ms`` (0: heartbeats off)
170
+ - ``status_too_large``: ``field`` (the field's name in AgentToServer, as the core gives it:
171
+ agent_description, health, effective_config, ...), ``bytes``
172
+ - ``bad_message``: ``detail``
173
+ - ``save_stalled``: ``stalled`` (True: the save under way has run for its stall time, and the
174
+ client waits on it still, saving nothing else meanwhile; False: it has ended at last),
175
+ ``waited_ms`` (how long it had run). A disk that hangs is the application's to see to.
176
+ - ``state_from_newer_version``: ``state_version`` (the saved state's format, a newer core's,
177
+ which this one can't read): the client starts from its configuration, as on first use, and
178
+ saves nothing (no offered connection settings, no packages), leaving that state as it is.
179
+ Worth a warning: an older release was started on a newer one's state file.
180
+ - ``stopped``: ``error`` (why the client stopped on its own, which stop() raises too; None
181
+ after stop())
182
+ """
183
+
184
+ kind: str
185
+ status: int | None = None
186
+ detail: str = ""
187
+ error_type: int = 0
188
+ message: str = ""
189
+ remote_config: pb.AgentRemoteConfig | None = None
190
+ connection_settings: pb.ConnectionSettingsOffers | None = None
191
+ custom_message: pb.CustomMessage | None = None
192
+ custom_capabilities: list[str] | None = None
193
+ command: pb.ServerToAgentCommand | None = None
194
+ package_name: str = ""
195
+ package: pb.PackageAvailable | None = None
196
+ path: str = ""
197
+ instance_uid: bytes = b""
198
+ heartbeat_interval_ms: int = 0
199
+ field: str = ""
200
+ bytes: int = 0
201
+ stalled: bool = False
202
+ waited_ms: int = 0
203
+ state_version: int = 0
204
+ error: BaseException | None = None
205
+
206
+
207
+ #: Once the core has stopped, at least this for what it gave up on to unwind (a download's file
208
+ #: deleted once the write in flight is over, a cancelled request's task ended), however much of
209
+ #: the stop timeout the core took: stop returns within stop_timeout_ms and this.
210
+ STOP_GRACE_S = 1.0
211
+
212
+ # The inputs that report an effect's outcome, each naming the effect by its id.
213
+ _RESULTS = frozenset({"http_response", "http_failed", "ws_opened", "ws_failed", "ws_message", "ws_closed", "ws_lost",
214
+ "stored", "store_failed", "downloaded", "download_failed", "download_response"})
215
+
216
+
217
+ def _names(input_: corepb.Input, id_: int) -> bool:
218
+ """Whether `input_` reports on effect `id_`."""
219
+ kind = input_.WhichOneof("kind")
220
+ return kind in _RESULTS and getattr(input_, kind).id == id_
221
+
222
+
223
+ def _notification(n: corepb.Notification, download_path: Callable[[int], str]) -> Notification | None:
224
+ kind = n.WhichOneof("kind")
225
+ if kind == "connected":
226
+ return Notification("connected")
227
+ if kind == "connect_failed":
228
+ status = n.connect_failed.status if n.connect_failed.HasField("status") else None
229
+ return Notification("connect_failed", status=status, detail=n.connect_failed.detail)
230
+ if kind == "error_response":
231
+ return Notification("error_response", error_type=n.error_response.kind, message=n.error_response.message)
232
+ if kind == "remote_config":
233
+ return Notification("remote_config", remote_config=n.remote_config)
234
+ if kind == "custom_message":
235
+ return Notification("custom_message", custom_message=n.custom_message)
236
+ if kind == "server_custom_capabilities":
237
+ return Notification("server_custom_capabilities",
238
+ custom_capabilities=list(n.server_custom_capabilities.capabilities))
239
+ if kind == "custom_message_not_sent":
240
+ return Notification("custom_message_not_sent", custom_message=n.custom_message_not_sent.message,
241
+ detail=n.custom_message_not_sent.reason)
242
+ if kind == "connection_settings":
243
+ return Notification("connection_settings", connection_settings=n.connection_settings)
244
+ if kind == "install_package":
245
+ p = n.install_package
246
+ return Notification("install_package", package_name=p.name, package=p.package,
247
+ path=download_path(p.download))
248
+ if kind == "remove_package":
249
+ return Notification("remove_package", package_name=n.remove_package.name)
250
+ if kind == "command":
251
+ return Notification("command", command=n.command)
252
+ if kind == "instance_uid_changed":
253
+ return Notification("instance_uid_changed", instance_uid=bytes(n.instance_uid_changed))
254
+ if kind == "heartbeat_interval_ms":
255
+ return Notification("heartbeat_interval", heartbeat_interval_ms=n.heartbeat_interval_ms)
256
+ if kind == "status_too_large":
257
+ s = n.status_too_large
258
+ return Notification("status_too_large", field=s.name, bytes=s.bytes)
259
+ if kind == "bad_message":
260
+ return Notification("bad_message", detail=n.bad_message.detail)
261
+ if kind == "save_stalled":
262
+ return Notification("save_stalled", stalled=n.save_stalled.stalled, waited_ms=n.save_stalled.waited_ms)
263
+ if kind == "state_from_newer_version":
264
+ return Notification("state_from_newer_version", state_version=n.state_from_newer_version.version)
265
+ if kind == "stopped":
266
+ return Notification("stopped")
267
+ return None
268
+
269
+
270
+ class Client:
271
+ """A running OpAMP client, on the current asyncio event loop. Use :meth:`start`. Its methods
272
+ may be called from any thread (a call from another thread is carried out on the loop), and
273
+ :meth:`stop` awaited on any loop (on another, it is carried out on the client's)."""
274
+
275
+ def __init__(self, config: Config, status: Status, notify: Callable[[Notification], None] | None,
276
+ _token: object, _core_engine: Any = None, _files: Files | None = None,
277
+ _sessions: Callable[[str], aiohttp.ClientSession] | None = None) -> None:
278
+ if _token is not _START:
279
+ raise TypeError("use Client.start")
280
+ # First: settings that can't work fail here, before anything is done.
281
+ # Nothing one mode alone honours is ignored: it is an error that says what to set.
282
+ if config.mode not in ("core", "platform"):
283
+ raise ValueError(f'mode is "core" or "platform", not {config.mode!r}')
284
+ core_mode = config.mode == "core"
285
+ if not core_mode and not config.splice_downloads:
286
+ raise ValueError('splice_downloads in platform mode: it is core mode\'s (aiohttp writes package files itself); '
287
+ 'leave it True, or leave mode as it is ("core")')
288
+ transport = Transport(config.tls, config.proxy, core_mode=core_mode)
289
+ # The core takes the transport from the endpoint's scheme, keeps every effect's time (and
290
+ # cancels what it gives up on), decides what a download's status means, and makes the
291
+ # instance UID if the application chose none, and the backoff's jitter seed from the
292
+ # entropy (seed left 0: two clients started in the same tick don't retry in step).
293
+ self._core = Core(corepb.Config(
294
+ instance_uid=config.instance_uid or b"",
295
+ entropy=os.urandom(32),
296
+ unix_ms=time.time_ns() // 1_000_000,
297
+ stop_timeout_ms=_u64(config.stop_timeout_ms),
298
+ connect_timeout_ms=_u64(config.connect_timeout_ms),
299
+ capabilities=config.capabilities,
300
+ heartbeat_interval_ms=_u64(config.heartbeat_interval_ms),
301
+ endpoint=config.endpoint,
302
+ headers=[corepb.Header(name=k, value=v) for k, v in config.headers.items()],
303
+ request_timeout_ms=_u64(config.request_timeout_ms),
304
+ max_send_bytes=_u64(config.max_send_bytes),
305
+ max_receive_bytes=_u64(config.max_receive_bytes),
306
+ persistence=config.state_file is not None,
307
+ saved_state=read_state(config.state_file),
308
+ accept_connection_settings=config.accept_connection_settings,
309
+ verify_timeout_ms=_u64(config.verify_timeout_ms),
310
+ accept_packages=config.accept_packages,
311
+ request_instance_uid=config.request_instance_uid,
312
+ max_package_bytes=_u64(config.max_package_bytes),
313
+ download_timeout_ms=_u64(config.download_timeout_ms),
314
+ streams=core_mode,
315
+ splice_downloads=core_mode and config.splice_downloads,
316
+ proxy=proxy_choice(transport) if core_mode else None,
317
+ ), _engine=_core_engine)
318
+ # All of stop takes the core's stop timeout, as given or by default: its start reports it.
319
+ self._stop_timeout = (self._core.limits.stop_timeout_ms if self._core.limits else 0) / 1000
320
+ self._stop_called: float | None = None # when stop() was first called (the loop's clock)
321
+ # The file system saves and download files go through; with the tap on, each operation
322
+ # is recorded there too, the state file named first.
323
+ files = _files or REAL
324
+ if config.state_file is not None and self._core.tapped:
325
+ files = Journal(files, self._core.record_file, config.state_file)
326
+ if config.state_file is not None:
327
+ # Files downloaded before a restart: deleted beside the loop (a slow disk holds up
328
+ # neither start nor the application), and before this client makes any of its own.
329
+ files = AfterClear(files, clear_downloads(files, config.state_file))
330
+ self._notify = notify or (lambda _n: None)
331
+ # Notifications from while the client is starting: delivered once `start` has returned.
332
+ self._held: list[Notification] | None = []
333
+ self._loop = asyncio.get_running_loop()
334
+ self._thread = threading.get_ident()
335
+ # The loop's clock (time.monotonic, unless a test's loop keeps its own).
336
+ self._start = self._loop.time()
337
+ self._x = Executor(self._feed, config.state_file, transport, files, sessions=_sessions)
338
+ # Core mode's streams and files; None otherwise.
339
+ self._streams = Streams(self._feed, transport, config.state_file, client=self._x.client,
340
+ files=files) if core_mode else None
341
+ self._queue: deque[corepb.Input] = deque()
342
+ self._draining = False
343
+ self._timer: asyncio.TimerHandle | None = None
344
+ self._done = self._loop.create_future()
345
+ self._finished = False
346
+ self._said_stopped = False
347
+ for name in ("description", "health", "effective_config", "remote_config_status", "available_components",
348
+ "custom_capabilities"):
349
+ value = getattr(status, name)
350
+ if value is not None:
351
+ self._feed(corepb.Input(**{f"set_{name}": value}))
352
+ self._feed(corepb.Input(start=corepb.Empty()))
353
+ self._loop.call_soon(self._release)
354
+
355
+ @classmethod
356
+ def start(cls, config: Config, status: Status,
357
+ notify: Callable[[Notification], None] | None = None, *,
358
+ _core_engine: Any = None, _files: Files | None = None,
359
+ _sessions: Callable[[str], aiohttp.ClientSession] | None = None) -> Client:
360
+ """Start a client on the running event loop. `notify` is called for every notification,
361
+ on the loop, never before `start` returns (a saved remote config is handed over at start):
362
+ keep it quick. TLS or proxy settings that can't work raise here: ValueError, or OSError
363
+ and ssl.SSLError for files and PEM that can't be loaded."""
364
+ # Tests only: _core_engine, run instead of the native core (the adversary); _files, a
365
+ # file system standing in for the real one; _sessions (Executor's).
366
+ return cls(config, status, notify, _START, _core_engine, _files, _sessions)
367
+
368
+ @property
369
+ def instance_uid(self) -> bytes:
370
+ """The instance UID in use: the application's, one saved before a restart, one the core
371
+ made, or one the server assigned (``instance_uid_changed`` tells of a new one)."""
372
+ return self._core.instance_uid
373
+
374
+ @property
375
+ def endpoint(self) -> str | None:
376
+ """The endpoint the client uses now: where its latest OpAMP request or WebSocket connection
377
+ went, which after the server moved the agent (an offer of connection settings) is the
378
+ offered one, or the configured one again once an offer failed. None before any outcome,
379
+ and in core mode, where the core makes the requests itself (the configured one is all
380
+ there is to name then). The URL is as the core gave it: redact it before logging."""
381
+ return None if self._streams is not None else self._x.used
382
+
383
+ def _release(self) -> None:
384
+ """Deliver what came while starting, now that the caller has the client."""
385
+ held, self._held = self._held or [], None
386
+ try:
387
+ for n in held:
388
+ self._notify(n)
389
+ except BaseException as e: # the application's callback
390
+ self._failed(e)
391
+
392
+ def set_description(self, value: pb.AgentDescription) -> None:
393
+ """Change the agent description."""
394
+ self._call(corepb.Input(set_description=value))
395
+
396
+ def set_health(self, value: pb.ComponentHealth) -> None:
397
+ """Change the health."""
398
+ self._call(corepb.Input(set_health=value))
399
+
400
+ def set_effective_config(self, value: pb.EffectiveConfig) -> None:
401
+ """Change the effective config."""
402
+ self._call(corepb.Input(set_effective_config=value))
403
+
404
+ def set_custom_capabilities(self, value: pb.CustomCapabilities) -> None:
405
+ """Change the custom capabilities the agent supports."""
406
+ self._call(corepb.Input(set_custom_capabilities=value))
407
+
408
+ def send_custom_message(self, value: pb.CustomMessage) -> None:
409
+ """Send a custom message, for one of the agent's custom capabilities. It goes with the next
410
+ message the server takes, and again if that message is lost; if it can't be sent at all, a
411
+ custom_message_not_sent notification says why."""
412
+ self._call(corepb.Input(send_custom_message=value))
413
+
414
+ def set_available_components(self, value: pb.AvailableComponents) -> None:
415
+ """Change the components the agent has (leave `hash` empty: the core computes it)."""
416
+ self._call(corepb.Input(set_available_components=value))
417
+
418
+ def set_remote_config_status(self, value: pb.RemoteConfigStatus) -> None:
419
+ """Report on a remote config (the answer to a remote_config notification)."""
420
+ self._call(corepb.Input(set_remote_config_status=value))
421
+
422
+ def set_connection_settings_status(self, value: pb.ConnectionSettingsStatus) -> None:
423
+ """Report on telemetry settings (the answer to a connection_settings notification)."""
424
+ self._call(corepb.Input(set_connection_settings_status=value))
425
+
426
+ def package_installed(self, name: str, hash_: bytes, error: str | None = None) -> None:
427
+ """Answer an install_package notification: the package at `hash_` is installed, or (`error`)
428
+ why it isn't."""
429
+ self._call(corepb.Input(package_installed=corepb.PackageInstalled(
430
+ name=name, hash=hash_, ok=error is None, detail=error or "")))
431
+
432
+ def package_removed(self, name: str, error: str | None = None) -> None:
433
+ """Answer a remove_package notification: the package is removed, or (`error`) why it isn't.
434
+ One that isn't is reported failed, and not removed again until it is offered and dropped
435
+ again."""
436
+ self._call(corepb.Input(package_removed=corepb.PackageRemoved(
437
+ name=name, failed=error is not None, detail=error or "")))
438
+
439
+ async def stop(self) -> None:
440
+ """Send agent_disconnect if a message can still be sent, then stop: this returns when the
441
+ core says it has stopped, within ``stop_timeout_ms`` (5 s by default). Nothing the client
442
+ started is running then. Awaited on another event loop (another thread's), it is carried
443
+ out on the client's, and returns (or raises) as it does there."""
444
+ if asyncio.get_running_loop() is not self._loop:
445
+ # The core is stepped on the client's loop only, and its futures are that loop's: fed
446
+ # from here, the stop raced the loop's own steps, and the await raised (a future of
447
+ # another loop) with the stop half done.
448
+ if not self._done.done():
449
+ if not self._loop.is_running():
450
+ raise RuntimeError("stop: the client's event loop isn't running")
451
+ there = asyncio.run_coroutine_threadsafe(self.stop(), self._loop)
452
+ here = asyncio.wrap_future(there)
453
+ # Waited on while the client's loop runs: a loop stopped before it ran the stop
454
+ # would leave it waiting forever.
455
+ while not (await asyncio.wait({here}, timeout=0.1))[0]:
456
+ if not self._loop.is_running():
457
+ there.cancel()
458
+ raise RuntimeError("stop: the client's event loop stopped before the stop was done")
459
+ here.result()
460
+ return self._done.result()
461
+ if self._stop_called is None:
462
+ self._stop_called = self._loop.time()
463
+ self._feed(corepb.Input(stop=corepb.Empty()))
464
+ await asyncio.shield(self._done)
465
+
466
+ def _call(self, input_: corepb.Input) -> None:
467
+ """An application's call: fed now on the loop's thread, from another thread on the loop."""
468
+ if threading.get_ident() == self._thread:
469
+ self._feed(input_)
470
+ elif not self._finished:
471
+ try:
472
+ self._loop.call_soon_threadsafe(self._feed, input_)
473
+ except RuntimeError:
474
+ # The loop is closed: the client can never step again, and a call after stop does
475
+ # nothing, from whichever thread it comes (on the loop's thread it already did).
476
+ if not self._loop.is_closed():
477
+ raise
478
+
479
+ def _now(self) -> int:
480
+ return int((self._loop.time() - self._start) * 1000)
481
+
482
+ def _feed(self, next_: corepb.Input) -> None:
483
+ """Step the core with every queued input, in order, carrying out the effects."""
484
+ if self._finished:
485
+ return
486
+ self._queue.append(next_)
487
+ if self._draining:
488
+ return
489
+ self._draining = True
490
+ try:
491
+ while self._queue and not self._finished:
492
+ out = self._core.step(self._now(), self._queue.popleft())
493
+ # stopped is told last: the core may emit a notification after it in the same
494
+ # output (install_package, in the step that answers a save after stop), and
495
+ # nothing reaches the application after stopped (RULE-SHELL-01).
496
+ stopped: Notification | None = None
497
+ for effect in out.effects:
498
+ kind = effect.WhichOneof("kind")
499
+ if kind is None:
500
+ # A newer module than this client: fail loudly rather than skip what the
501
+ # core asked for.
502
+ raise RuntimeError("the core asked for an effect this client doesn't know: the client is older than its core")
503
+ if kind == "notify":
504
+ n = _notification(effect.notify, self._x.download_path)
505
+ if n is not None and n.kind == "stopped":
506
+ stopped = n
507
+ elif n is not None:
508
+ self._tell(n)
509
+ else:
510
+ if kind == "cancel":
511
+ # Nothing more about it reaches the core, not even what is queued.
512
+ # covers: RULE-CLIENT-15
513
+ id_ = effect.cancel.id
514
+ self._queue = deque(i for i in self._queue if not _names(i, id_))
515
+ if self._streams is None or not self._streams.do(effect):
516
+ self._x.do(effect)
517
+ if stopped is not None:
518
+ self._tell(stopped)
519
+ if self._timer is not None:
520
+ self._timer.cancel()
521
+ self._timer = None
522
+ if out.HasField("wake_at_ms"):
523
+ delay = max(0, out.wake_at_ms - self._now()) / 1000
524
+ self._timer = self._loop.call_later(delay, self._feed, corepb.Input(timer=corepb.Empty()))
525
+ # Core mode: the core ends its own goodbye and closing handshakes, within its stop
526
+ # time, and has closed every stream by now.
527
+ if out.stopped:
528
+ self._end()
529
+ except BaseException as e: # a CoreError, a bug, or the application's callback
530
+ self._failed(e)
531
+ finally:
532
+ self._draining = False
533
+
534
+ def _failed(self, e: BaseException) -> None:
535
+ """Something raised: the client stops, and stop() raises it too. KeyboardInterrupt and the
536
+ like go on up after."""
537
+ if not self._finished:
538
+ self._end(e)
539
+ if not isinstance(e, Exception):
540
+ raise e
541
+
542
+ def _tell(self, n: Notification) -> None:
543
+ """Tell the application, or hold it until start has returned."""
544
+ self._said_stopped |= n.kind == "stopped"
545
+ if self._held is not None:
546
+ self._held.append(n)
547
+ else:
548
+ self._notify(n)
549
+
550
+ def _end(self, err: BaseException | None = None) -> None:
551
+ self._finished = True
552
+ if self._timer is not None:
553
+ self._timer.cancel()
554
+ self._core.close()
555
+ if err is not None and not self._said_stopped:
556
+ # The client stopped on its own: the application hears it now, not only from stop().
557
+ # If its callback is what failed, it may fail again: that is already the error reported.
558
+ try:
559
+ self._tell(Notification("stopped", error=err))
560
+ except Exception:
561
+ pass
562
+
563
+ async def finish() -> None:
564
+ # The core has already waited for the goodbye: anything left is dropped. All of stop
565
+ # takes stop_timeout: what the core took of it (from stop()) is not given again, but
566
+ # for STOP_GRACE_S at least for what was given up on to unwind (RULE-SHELL-07: it is
567
+ # released, the deletion of a download's file included, by the time stop returns). A
568
+ # core that takes all of its stop timeout and then says stopped with a download's file
569
+ # still being written leaves the shell to delete it: given nothing, it was deleted
570
+ # after stop had returned.
571
+ loop = self._loop
572
+ begun = loop.time() if self._stop_called is None else self._stop_called
573
+ left = begun + self._stop_timeout - loop.time()
574
+ released_by = loop.time() + max(left, STOP_GRACE_S)
575
+ # A save still writing lands whole whenever it does: waited for within the stop
576
+ # timeout only. Core mode: the streams go at once, beside it (a slow save holds no
577
+ # connection open), and files still being written are deleted once their write in
578
+ # flight is over.
579
+ work = [self._x.close(max(0.0, left), release=released_by - loop.time())]
580
+ if self._streams is not None:
581
+ work.append(self._streams.shutdown(max(0.0, released_by - loop.time())))
582
+ await asyncio.gather(*work)
583
+ if not self._done.done():
584
+ if err is None:
585
+ self._done.set_result(None)
586
+ else:
587
+ self._done.set_exception(err)
588
+
589
+ self._finish_task = self._loop.create_task(finish())
590
+
591
+
592
+ _START = object()