hostctl 0.1.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.
- hostctl/AGENTS.md +292 -0
- hostctl/README.md +248 -0
- hostctl/__init__.py +194 -0
- hostctl/__main__.py +6 -0
- hostctl/_async.py +169 -0
- hostctl/_cli.py +232 -0
- hostctl/executor/__init__.py +82 -0
- hostctl/executor/_common.py +234 -0
- hostctl/executor/_qga.py +646 -0
- hostctl/executor/container.py +171 -0
- hostctl/executor/local.py +91 -0
- hostctl/executor/psrp.py +145 -0
- hostctl/executor/qemu.py +305 -0
- hostctl/executor/serial.py +312 -0
- hostctl/executor/ssh.py +226 -0
- hostctl/executor/winrm.py +255 -0
- hostctl/host/__init__.py +93 -0
- hostctl/host/_common.py +804 -0
- hostctl/host/_local.py +258 -0
- hostctl/host/_ssh.py +587 -0
- hostctl/host/_winrm.py +1002 -0
- hostctl/host/composite_path.py +567 -0
- hostctl/host/container.py +606 -0
- hostctl/host/container_path.py +664 -0
- hostctl/host/qemu.py +1078 -0
- hostctl/host/serial.py +401 -0
- hostctl/host/system.py +809 -0
- hostctl/process/__init__.py +49 -0
- hostctl/process/_common.py +113 -0
- hostctl/process/container.py +265 -0
- hostctl/process/psrp.py +208 -0
- hostctl/process/qemu_serial.py +247 -0
- hostctl/process/serial.py +257 -0
- hostctl/process/ssh.py +165 -0
- hostctl/provider/__init__.py +33 -0
- hostctl/provider/_common.py +452 -0
- hostctl/provider/transports.py +303 -0
- hostctl/py.typed +0 -0
- hostctl/serial/__init__.py +285 -0
- hostctl/shell/__init__.py +123 -0
- hostctl/shell/_common.py +616 -0
- hostctl/shell/cmd.py +167 -0
- hostctl/shell/fish.py +63 -0
- hostctl/shell/posix.py +83 -0
- hostctl/shell/powershell.py +120 -0
- hostctl/sync.py +245 -0
- hostctl-0.1.0.dist-info/METADATA +302 -0
- hostctl-0.1.0.dist-info/RECORD +51 -0
- hostctl-0.1.0.dist-info/WHEEL +4 -0
- hostctl-0.1.0.dist-info/entry_points.txt +2 -0
- hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""Small, transport-independent provider and selection contracts.
|
|
2
|
+
|
|
3
|
+
Providers are deliberately adapters: they own connection-specific behaviour,
|
|
4
|
+
while :mod:`hostctl.host.system` owns operating-system semantics and fallback
|
|
5
|
+
safety.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import typing
|
|
14
|
+
|
|
15
|
+
from pathlib_next import Path
|
|
16
|
+
|
|
17
|
+
#: Selection, probe, and decline diagnostics. Library convention: this module
|
|
18
|
+
#: never configures a handler and never calls ``basicConfig``; an application
|
|
19
|
+
#: opts in with ``logging.getLogger("hostctl").setLevel(logging.DEBUG)``.
|
|
20
|
+
log = logging.getLogger("hostctl.provider")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclasses.dataclass(frozen=True)
|
|
24
|
+
class ProviderProbe:
|
|
25
|
+
availability: typing.Literal["available", "unavailable", "degraded"]
|
|
26
|
+
reason: str = ""
|
|
27
|
+
capabilities: frozenset[str] = frozenset()
|
|
28
|
+
system_hint: typing.Optional[str] = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def usable(self) -> bool:
|
|
32
|
+
return self.availability in ("available", "degraded")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OperationNotStarted(RuntimeError):
|
|
36
|
+
"""A provider declined before a remote operation could start."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
reason: str = "operation was not started",
|
|
41
|
+
*,
|
|
42
|
+
cause: Exception | None = None,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(reason)
|
|
45
|
+
self.reason = reason
|
|
46
|
+
self.cause = cause
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclasses.dataclass(frozen=True)
|
|
50
|
+
class ProviderSelection:
|
|
51
|
+
provider: typing.Union["ExecutorProvider", "PathProvider"]
|
|
52
|
+
trace: tuple[dict[str, object], ...]
|
|
53
|
+
generation: int = 0
|
|
54
|
+
policy: str = "ordered"
|
|
55
|
+
pinned: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass(frozen=True)
|
|
59
|
+
class SessionInitializer:
|
|
60
|
+
"""Optional post-connect bootstrap hook receiving the connected system host."""
|
|
61
|
+
|
|
62
|
+
initialize: typing.Callable[..., object]
|
|
63
|
+
timeout: float | None = None
|
|
64
|
+
|
|
65
|
+
def __call__(self, host, **options):
|
|
66
|
+
if self.timeout is not None:
|
|
67
|
+
options.setdefault("timeout", self.timeout)
|
|
68
|
+
return self.initialize(host, **options)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _LazyRedaction:
|
|
72
|
+
"""A log argument that redacts only if a handler actually formats it.
|
|
73
|
+
|
|
74
|
+
``log.debug("... %s", _LazyRedaction(cmd, args))`` costs one object
|
|
75
|
+
allocation when nobody is listening; the regex work happens inside
|
|
76
|
+
``__str__``, which ``logging`` calls only while rendering a record it has
|
|
77
|
+
decided to emit.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
__slots__ = ("_command", "_args")
|
|
81
|
+
|
|
82
|
+
def __init__(self, command, args=()):
|
|
83
|
+
self._command = command
|
|
84
|
+
self._args = tuple(args)
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
parts = (self._command, *self._args)
|
|
88
|
+
return ProviderSelector.redact(" ".join(str(part) for part in parts))
|
|
89
|
+
|
|
90
|
+
__repr__ = __str__
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _redacted_command(command, args=()) -> _LazyRedaction:
|
|
94
|
+
"""Wrap a command and its argv for redacted, deferred log formatting."""
|
|
95
|
+
return _LazyRedaction(command, args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ExecutorProvider:
|
|
99
|
+
"""Named callable executor with a conservative probe hook."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
name: str,
|
|
104
|
+
executor: typing.Callable[..., object],
|
|
105
|
+
*,
|
|
106
|
+
capabilities=None,
|
|
107
|
+
probe=None,
|
|
108
|
+
):
|
|
109
|
+
if not name:
|
|
110
|
+
raise ValueError("provider name must not be empty")
|
|
111
|
+
self.name = name
|
|
112
|
+
self.executor = executor
|
|
113
|
+
raw = (
|
|
114
|
+
getattr(executor, "executor_capabilities", ())
|
|
115
|
+
if capabilities is None
|
|
116
|
+
else capabilities
|
|
117
|
+
)
|
|
118
|
+
self.capabilities = frozenset(getattr(item, "value", item) for item in raw)
|
|
119
|
+
self._probe = probe
|
|
120
|
+
|
|
121
|
+
def probe(self) -> ProviderProbe:
|
|
122
|
+
if self._probe is None:
|
|
123
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
124
|
+
result = self._probe()
|
|
125
|
+
if isinstance(result, ProviderProbe):
|
|
126
|
+
return dataclasses.replace(
|
|
127
|
+
result,
|
|
128
|
+
capabilities=frozenset(
|
|
129
|
+
getattr(item, "value", item) for item in result.capabilities
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
return ProviderProbe(
|
|
133
|
+
"available" if result else "unavailable", capabilities=self.capabilities
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def execute(self, command, *args, **options):
|
|
137
|
+
# The rendered command routinely carries credentials, so it is redacted
|
|
138
|
+
# on the way to the record and never interpolated eagerly: with no
|
|
139
|
+
# handler listening, `%`-style args mean the redaction never runs.
|
|
140
|
+
log.debug(
|
|
141
|
+
"provider %s dispatching: %s",
|
|
142
|
+
ProviderSelector.redact(self.name),
|
|
143
|
+
_redacted_command(command, args),
|
|
144
|
+
)
|
|
145
|
+
return self.executor(command, *args, **options)
|
|
146
|
+
|
|
147
|
+
__call__ = execute
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PathProvider:
|
|
151
|
+
"""Named path factory. ``path`` must return a pathlib_next.Path."""
|
|
152
|
+
|
|
153
|
+
DEFAULT_CAPABILITIES = frozenset(
|
|
154
|
+
(
|
|
155
|
+
"stat",
|
|
156
|
+
"scandir",
|
|
157
|
+
"open",
|
|
158
|
+
"open_read",
|
|
159
|
+
"open_write",
|
|
160
|
+
"read",
|
|
161
|
+
"write",
|
|
162
|
+
"exists",
|
|
163
|
+
"is_file",
|
|
164
|
+
"is_dir",
|
|
165
|
+
"mkdir",
|
|
166
|
+
"chmod",
|
|
167
|
+
"unlink",
|
|
168
|
+
"rmdir",
|
|
169
|
+
"rename",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
name: str,
|
|
176
|
+
factory: typing.Callable[..., Path],
|
|
177
|
+
*,
|
|
178
|
+
capabilities=None,
|
|
179
|
+
probe=None,
|
|
180
|
+
):
|
|
181
|
+
if not name:
|
|
182
|
+
raise ValueError("provider name must not be empty")
|
|
183
|
+
self.name = name
|
|
184
|
+
self.factory = factory
|
|
185
|
+
raw_capabilities = (
|
|
186
|
+
self.DEFAULT_CAPABILITIES if capabilities is None else capabilities
|
|
187
|
+
)
|
|
188
|
+
self.capabilities = frozenset(
|
|
189
|
+
getattr(item, "value", item) for item in raw_capabilities
|
|
190
|
+
)
|
|
191
|
+
self._probe = probe
|
|
192
|
+
|
|
193
|
+
def probe(self) -> ProviderProbe:
|
|
194
|
+
if self._probe is None:
|
|
195
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
196
|
+
result = self._probe()
|
|
197
|
+
if isinstance(result, ProviderProbe):
|
|
198
|
+
return dataclasses.replace(
|
|
199
|
+
result,
|
|
200
|
+
capabilities=frozenset(
|
|
201
|
+
getattr(item, "value", item) for item in result.capabilities
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
return ProviderProbe(
|
|
205
|
+
"available" if result else "unavailable", capabilities=self.capabilities
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def path(self, *segments):
|
|
209
|
+
log.debug(
|
|
210
|
+
"path provider %s building: %s",
|
|
211
|
+
ProviderSelector.redact(self.name),
|
|
212
|
+
_redacted_command("", segments),
|
|
213
|
+
)
|
|
214
|
+
value = self.factory(*segments)
|
|
215
|
+
if not isinstance(value, Path):
|
|
216
|
+
raise TypeError(
|
|
217
|
+
f"path provider {self.name!r} returned {type(value).__name__}, expected pathlib_next.Path"
|
|
218
|
+
)
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ProviderSelector:
|
|
223
|
+
"""Ordered selector which never retries a dispatched operation."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, providers=()):
|
|
226
|
+
self.providers = tuple(providers)
|
|
227
|
+
if any(not getattr(p, "name", "") for p in self.providers):
|
|
228
|
+
raise ValueError("providers must have names")
|
|
229
|
+
names = tuple(provider.name for provider in self.providers)
|
|
230
|
+
if len(set(names)) != len(names):
|
|
231
|
+
raise ValueError("provider names must be unique within a selector")
|
|
232
|
+
self.last_selection: ProviderSelection | None = None
|
|
233
|
+
self._probe_cache: dict[str, ProviderProbe] = {}
|
|
234
|
+
self._declined: dict[str, str] = {}
|
|
235
|
+
self._generation = 0
|
|
236
|
+
# Trace entries accumulated across the failover attempts of one
|
|
237
|
+
# caller-level operation, keyed by provider name so a later, more
|
|
238
|
+
# informative record (a decline) supersedes the earlier optimistic one
|
|
239
|
+
# (chosen). See `select` for how an attempt sequence is delimited.
|
|
240
|
+
self._attempt_trace: dict[str, dict[str, object]] = {}
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def generation(self) -> int:
|
|
244
|
+
"""The current connection generation; probes are cached per value."""
|
|
245
|
+
return self._generation
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def declines(self) -> dict[str, str]:
|
|
249
|
+
"""Providers that refused before dispatch this generation, by name.
|
|
250
|
+
|
|
251
|
+
Maps provider name to the redacted refusal reason. A copy: mutating
|
|
252
|
+
the result does not change selector state.
|
|
253
|
+
"""
|
|
254
|
+
return {
|
|
255
|
+
name: self._safe_name(reason) for name, reason in self._declined.items()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
def decline(self, name: str, reason: str = "declined before dispatch") -> None:
|
|
259
|
+
"""Record that a provider refused *before* dispatch this generation.
|
|
260
|
+
|
|
261
|
+
A provider which raised ``OperationNotStarted`` proved that nothing
|
|
262
|
+
started, so it is safe to skip it for the rest of this generation
|
|
263
|
+
rather than re-attempting it on every later operation. The record is
|
|
264
|
+
cleared by :meth:`invalidate` along with the probe cache.
|
|
265
|
+
"""
|
|
266
|
+
self._declined[str(name)] = str(reason)
|
|
267
|
+
log.debug(
|
|
268
|
+
"provider %s declined before dispatch (generation %d): %s",
|
|
269
|
+
self._safe_name(name),
|
|
270
|
+
self._generation,
|
|
271
|
+
self._safe_name(reason),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
#: ``name=value`` and ``name: value`` credential assignments.
|
|
275
|
+
_SECRET_ASSIGNMENT = re.compile(
|
|
276
|
+
r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|key|credential)"
|
|
277
|
+
r"\s*[=:]\s*(\"[^\"]*\"|'[^']*'|[^&\s,;]+)"
|
|
278
|
+
)
|
|
279
|
+
#: ``scheme://user:password@host`` URI userinfo.
|
|
280
|
+
_SECRET_USERINFO = re.compile(r"(?i)\b([a-z][a-z0-9+.\-]*://[^/\s:@]+):[^/\s@]+@")
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def _safe_name(cls, value: object) -> str:
|
|
284
|
+
text = str(value)
|
|
285
|
+
text = cls._SECRET_ASSIGNMENT.sub(r"\1=<redacted>", text)
|
|
286
|
+
return cls._SECRET_USERINFO.sub(r"\1:<redacted>@", text)
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def redact(cls, value: object) -> str:
|
|
290
|
+
"""Return a diagnostic string with credential-like values removed.
|
|
291
|
+
|
|
292
|
+
Best effort, and deliberately so. This recognizes credentials that
|
|
293
|
+
*announce themselves* -- a ``password=``/``token=``/``api_key=``
|
|
294
|
+
assignment, or the userinfo half of a ``scheme://user:secret@host``
|
|
295
|
+
URI. A bare positional secret (``mysql -p hunter2``, an argv element
|
|
296
|
+
that is only a token) carries no marker and cannot be detected by
|
|
297
|
+
inspection. Treat debug output as sensitive.
|
|
298
|
+
"""
|
|
299
|
+
return cls._safe_name(value)
|
|
300
|
+
|
|
301
|
+
def probe(self, provider) -> ProviderProbe:
|
|
302
|
+
"""Return a provider probe cached for this selector generation.
|
|
303
|
+
|
|
304
|
+
A provider that declined before dispatch this generation reports as
|
|
305
|
+
``unavailable`` with the refusal reason, overriding the cached probe.
|
|
306
|
+
The probe describes whether the transport *could* work; the decline is
|
|
307
|
+
the newer and stronger evidence that right now it does not, so any
|
|
308
|
+
caller reading availability (``provider_details``, ``capabilities``)
|
|
309
|
+
sees the refusal instead of a stale ``available``.
|
|
310
|
+
"""
|
|
311
|
+
name = provider.name
|
|
312
|
+
declined = self._declined.get(name)
|
|
313
|
+
if declined is not None:
|
|
314
|
+
return ProviderProbe("unavailable", self._safe_name(declined))
|
|
315
|
+
if name in self._probe_cache:
|
|
316
|
+
return self._probe_cache[name]
|
|
317
|
+
try:
|
|
318
|
+
result = provider.probe()
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
result = ProviderProbe("unavailable", type(exc).__name__)
|
|
321
|
+
log.debug(
|
|
322
|
+
"provider %s probe raised %s; treating as unavailable",
|
|
323
|
+
self._safe_name(name),
|
|
324
|
+
type(exc).__name__,
|
|
325
|
+
)
|
|
326
|
+
self._probe_cache[name] = result
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
def select(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
capability: str | None = None,
|
|
333
|
+
exclude: typing.Iterable[str] = (),
|
|
334
|
+
policy: str = "ordered",
|
|
335
|
+
pin: bool = False,
|
|
336
|
+
) -> ProviderSelection:
|
|
337
|
+
excluded = set(exclude)
|
|
338
|
+
# An empty `exclude` starts a new caller-level operation; a non-empty
|
|
339
|
+
# one is a failover step within the operation that is already running,
|
|
340
|
+
# because the only way a name lands in `exclude` is that this same
|
|
341
|
+
# caller just tried it (`run()`, `_providers_in_order`). Accumulating
|
|
342
|
+
# across those steps is what makes the FIRST failing call report every
|
|
343
|
+
# provider tried and every refusal, instead of the trace describing
|
|
344
|
+
# only whichever provider happened to win.
|
|
345
|
+
if not excluded:
|
|
346
|
+
self._attempt_trace = {}
|
|
347
|
+
for provider in self.providers:
|
|
348
|
+
declined = self._declined.get(provider.name)
|
|
349
|
+
if declined is not None:
|
|
350
|
+
# Recorded even when the caller also excluded this provider:
|
|
351
|
+
# `exclude` says "I already tried it", and the decline is the
|
|
352
|
+
# reason it did not work. Skipping the record here is exactly
|
|
353
|
+
# what used to strip the refusal from the trace of the call
|
|
354
|
+
# that suffered it.
|
|
355
|
+
self._record(
|
|
356
|
+
{
|
|
357
|
+
"provider": self._safe_name(provider.name),
|
|
358
|
+
"availability": "unavailable",
|
|
359
|
+
"reason": self._safe_name(declined),
|
|
360
|
+
"capabilities": (),
|
|
361
|
+
"chosen": False,
|
|
362
|
+
"generation": self._generation,
|
|
363
|
+
"policy": self._safe_name(policy),
|
|
364
|
+
"pin": bool(pin),
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
continue
|
|
368
|
+
if provider.name in excluded:
|
|
369
|
+
continue
|
|
370
|
+
probe = self.probe(provider)
|
|
371
|
+
allowed = probe.usable and (
|
|
372
|
+
capability is None
|
|
373
|
+
or capability in (probe.capabilities | provider.capabilities)
|
|
374
|
+
)
|
|
375
|
+
capabilities = frozenset(
|
|
376
|
+
getattr(item, "value", item) for item in probe.capabilities
|
|
377
|
+
)
|
|
378
|
+
self._record(
|
|
379
|
+
{
|
|
380
|
+
"provider": self._safe_name(provider.name),
|
|
381
|
+
"availability": probe.availability,
|
|
382
|
+
"reason": self._safe_name(probe.reason),
|
|
383
|
+
"capabilities": tuple(sorted(capabilities)),
|
|
384
|
+
"chosen": allowed,
|
|
385
|
+
"generation": self._generation,
|
|
386
|
+
"policy": self._safe_name(policy),
|
|
387
|
+
"pin": bool(pin),
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
if not allowed:
|
|
391
|
+
log.debug(
|
|
392
|
+
"provider %s skipped: availability=%s capability=%s reason=%s",
|
|
393
|
+
self._safe_name(provider.name),
|
|
394
|
+
probe.availability,
|
|
395
|
+
capability or "<any>",
|
|
396
|
+
self._safe_name(probe.reason) or "<none>",
|
|
397
|
+
)
|
|
398
|
+
continue
|
|
399
|
+
result = ProviderSelection(
|
|
400
|
+
provider,
|
|
401
|
+
self.trace,
|
|
402
|
+
generation=self._generation,
|
|
403
|
+
policy=policy,
|
|
404
|
+
pinned=bool(pin),
|
|
405
|
+
)
|
|
406
|
+
self.last_selection = result
|
|
407
|
+
log.debug(
|
|
408
|
+
"selected provider %s (generation %d, policy %s, pin %s) "
|
|
409
|
+
"after %d candidate(s)",
|
|
410
|
+
self._safe_name(provider.name),
|
|
411
|
+
self._generation,
|
|
412
|
+
self._safe_name(policy),
|
|
413
|
+
bool(pin),
|
|
414
|
+
len(self._attempt_trace),
|
|
415
|
+
)
|
|
416
|
+
return result
|
|
417
|
+
log.debug(
|
|
418
|
+
"no provider available (generation %d); candidates: %s",
|
|
419
|
+
self._generation,
|
|
420
|
+
", ".join(
|
|
421
|
+
"%s/%s" % (item["provider"], item["availability"])
|
|
422
|
+
for item in self.trace
|
|
423
|
+
)
|
|
424
|
+
or "<none>",
|
|
425
|
+
)
|
|
426
|
+
raise OperationNotStarted("no provider is available")
|
|
427
|
+
|
|
428
|
+
def _record(self, entry: dict[str, object]) -> None:
|
|
429
|
+
"""Merge one trace entry, letting a later record supersede an earlier.
|
|
430
|
+
|
|
431
|
+
Ordering follows first appearance, so the trace still reads in
|
|
432
|
+
provider precedence order after a decline rewrites an entry.
|
|
433
|
+
"""
|
|
434
|
+
name = typing.cast(str, entry["provider"])
|
|
435
|
+
if name in self._attempt_trace:
|
|
436
|
+
self._attempt_trace[name].update(entry)
|
|
437
|
+
else:
|
|
438
|
+
self._attempt_trace[name] = dict(entry)
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def trace(self) -> tuple[dict[str, object], ...]:
|
|
442
|
+
"""The accumulated trace for the operation currently being selected."""
|
|
443
|
+
return tuple(dict(entry) for entry in self._attempt_trace.values())
|
|
444
|
+
|
|
445
|
+
def invalidate(self) -> None:
|
|
446
|
+
"""Start a new generation, dropping cached probes and declines."""
|
|
447
|
+
self.last_selection = None
|
|
448
|
+
self._probe_cache.clear()
|
|
449
|
+
self._declined.clear()
|
|
450
|
+
self._attempt_trace = {}
|
|
451
|
+
self._generation += 1
|
|
452
|
+
log.debug("selector invalidated; generation is now %d", self._generation)
|