bazaar-core 0.3.0rc1__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.
- bazaar_core/__init__.py +69 -0
- bazaar_core/assembly.py +564 -0
- bazaar_core/bazaar.py +1012 -0
- bazaar_core/contracts.py +346 -0
- bazaar_core/discovery.py +98 -0
- bazaar_core/instances.py +430 -0
- bazaar_core/lifecycle.py +74 -0
- bazaar_core/py.typed +0 -0
- bazaar_core-0.3.0rc1.dist-info/METADATA +66 -0
- bazaar_core-0.3.0rc1.dist-info/RECORD +12 -0
- bazaar_core-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_core-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
bazaar_core/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Public Bazaar Compute Node contrib framework contracts."""
|
|
2
|
+
|
|
3
|
+
lazy from .bazaar import ContribHub
|
|
4
|
+
lazy from .contracts import (
|
|
5
|
+
API_VERSION,
|
|
6
|
+
COMPOSITION,
|
|
7
|
+
CONTRIB_CHANGED,
|
|
8
|
+
CURRENT_CONTEXT,
|
|
9
|
+
BindingRef,
|
|
10
|
+
Blocked,
|
|
11
|
+
Candidate,
|
|
12
|
+
Composition,
|
|
13
|
+
Context,
|
|
14
|
+
Contrib,
|
|
15
|
+
ContribChange,
|
|
16
|
+
ContribEntry,
|
|
17
|
+
ContribError,
|
|
18
|
+
ContribSource,
|
|
19
|
+
ContribState,
|
|
20
|
+
ContribStatus,
|
|
21
|
+
EventHandler,
|
|
22
|
+
EventKey,
|
|
23
|
+
Failed,
|
|
24
|
+
Lifespan,
|
|
25
|
+
Running,
|
|
26
|
+
ServiceKey,
|
|
27
|
+
Starting,
|
|
28
|
+
Stopped,
|
|
29
|
+
Stopping,
|
|
30
|
+
)
|
|
31
|
+
lazy from .discovery import CONTRIB_GROUP, ContribSpec, discover, load
|
|
32
|
+
lazy from .instances import Enablement
|
|
33
|
+
lazy from .lifecycle import TaskFailureSignal, TaskFailureSource
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"API_VERSION",
|
|
37
|
+
"COMPOSITION",
|
|
38
|
+
"CONTRIB_CHANGED",
|
|
39
|
+
"CONTRIB_GROUP",
|
|
40
|
+
"CURRENT_CONTEXT",
|
|
41
|
+
"BindingRef",
|
|
42
|
+
"Blocked",
|
|
43
|
+
"Candidate",
|
|
44
|
+
"Composition",
|
|
45
|
+
"Context",
|
|
46
|
+
"Contrib",
|
|
47
|
+
"ContribChange",
|
|
48
|
+
"ContribEntry",
|
|
49
|
+
"ContribError",
|
|
50
|
+
"ContribHub",
|
|
51
|
+
"ContribSource",
|
|
52
|
+
"ContribSpec",
|
|
53
|
+
"ContribState",
|
|
54
|
+
"ContribStatus",
|
|
55
|
+
"Enablement",
|
|
56
|
+
"EventHandler",
|
|
57
|
+
"EventKey",
|
|
58
|
+
"Failed",
|
|
59
|
+
"Lifespan",
|
|
60
|
+
"Running",
|
|
61
|
+
"ServiceKey",
|
|
62
|
+
"Starting",
|
|
63
|
+
"Stopped",
|
|
64
|
+
"Stopping",
|
|
65
|
+
"TaskFailureSignal",
|
|
66
|
+
"TaskFailureSource",
|
|
67
|
+
"discover",
|
|
68
|
+
"load",
|
|
69
|
+
]
|
bazaar_core/assembly.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Complete instance graphs, service bindings, and dependency ordering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
lazy from collections.abc import Sequence
|
|
7
|
+
lazy from dataclasses import dataclass, replace
|
|
8
|
+
lazy from heapq import heapify, heappop, heappush
|
|
9
|
+
lazy from uuid import UUID, uuid7
|
|
10
|
+
|
|
11
|
+
from pydantic import ConfigDict, TypeAdapter
|
|
12
|
+
lazy from packaging.specifiers import SpecifierSet
|
|
13
|
+
lazy from packaging.version import Version
|
|
14
|
+
|
|
15
|
+
from .contracts import ContribError, EventKey, ServiceKey
|
|
16
|
+
lazy from .bazaar import ContribHub
|
|
17
|
+
lazy from .contracts import (
|
|
18
|
+
API_VERSION,
|
|
19
|
+
Blocked,
|
|
20
|
+
Candidate,
|
|
21
|
+
Composition,
|
|
22
|
+
ContribStatus,
|
|
23
|
+
Failed,
|
|
24
|
+
Stopping,
|
|
25
|
+
)
|
|
26
|
+
lazy from .instances import Enablement, _ContribInstance, _Open, _Registration
|
|
27
|
+
|
|
28
|
+
_LOCAL = TypeAdapter(
|
|
29
|
+
tuple[ServiceKey[Any] | EventKey[Any], ...],
|
|
30
|
+
config=ConfigDict(arbitrary_types_allowed=True),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate(
|
|
35
|
+
hub: ContribHub,
|
|
36
|
+
owner: Enablement,
|
|
37
|
+
composition: Composition,
|
|
38
|
+
registrations: Sequence[_Registration],
|
|
39
|
+
) -> Candidate:
|
|
40
|
+
"""Bind every concrete instance before acquiring any resource."""
|
|
41
|
+
instances: list[_ContribInstance] = []
|
|
42
|
+
diagnostics = list(composition.diagnostics)
|
|
43
|
+
unavailable = {
|
|
44
|
+
registration.id
|
|
45
|
+
for registration in registrations
|
|
46
|
+
for snapshot in registration.owner.snapshots
|
|
47
|
+
if snapshot.name == registration.entry.name
|
|
48
|
+
and snapshot.location == registration.entry.location
|
|
49
|
+
and isinstance(snapshot.state, Stopping)
|
|
50
|
+
}
|
|
51
|
+
for registration in registrations:
|
|
52
|
+
entry = registration.entry
|
|
53
|
+
definition = entry.definition
|
|
54
|
+
label = f"contrib {entry.name!r} at {entry.location}"
|
|
55
|
+
try:
|
|
56
|
+
if Version(API_VERSION) not in SpecifierSet(definition.api):
|
|
57
|
+
raise ContribError(
|
|
58
|
+
f"{label}: requires core API {definition.api}, "
|
|
59
|
+
f"but this hub provides {API_VERSION}"
|
|
60
|
+
)
|
|
61
|
+
for key in entry.required_offers:
|
|
62
|
+
if key not in definition.offers:
|
|
63
|
+
raise ContribError(
|
|
64
|
+
f"{label}: required service {key.name!r} is not declared in Contrib.offers"
|
|
65
|
+
)
|
|
66
|
+
config = (
|
|
67
|
+
registration.config.model_copy(deep=True)
|
|
68
|
+
if registration.config is not None
|
|
69
|
+
else None
|
|
70
|
+
)
|
|
71
|
+
accepts = definition.dependencies(config)
|
|
72
|
+
instances.append(_ContribInstance(registration, config, accepts))
|
|
73
|
+
except Exception as error:
|
|
74
|
+
if (
|
|
75
|
+
not composition.isolate_errors
|
|
76
|
+
and composition.roots is None
|
|
77
|
+
and registration.id not in unavailable
|
|
78
|
+
):
|
|
79
|
+
raise ContribError(
|
|
80
|
+
f"{label}: cannot validate contrib definition: {error}"
|
|
81
|
+
) from error
|
|
82
|
+
if registration.owner is owner:
|
|
83
|
+
diagnostics.append(
|
|
84
|
+
ContribStatus(
|
|
85
|
+
uuid7(),
|
|
86
|
+
entry.name,
|
|
87
|
+
entry.location,
|
|
88
|
+
Failed(error=str(error), phase="stopped"),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
by_registration = {instance.registration.id: instance for instance in instances}
|
|
92
|
+
for instance in instances:
|
|
93
|
+
instance._parents = tuple(
|
|
94
|
+
by_registration.get(parent.registration.id, parent)
|
|
95
|
+
for parent in instance._parents
|
|
96
|
+
)
|
|
97
|
+
declarations: dict[tuple[Enablement, str, str], list[_ContribInstance]] = {}
|
|
98
|
+
scoped: dict[tuple[Enablement, ServiceKey[Any]], list[_ContribInstance]] = {}
|
|
99
|
+
for instance in instances:
|
|
100
|
+
declarations.setdefault(
|
|
101
|
+
(instance.enablement, instance.entry.name, instance.entry.location), []
|
|
102
|
+
).append(instance)
|
|
103
|
+
for key in instance.definition.offers:
|
|
104
|
+
scoped.setdefault((instance.enablement, key), []).append(instance)
|
|
105
|
+
resolved: set[tuple[_ContribInstance, ServiceKey[Any] | EventKey[Any]]] = set()
|
|
106
|
+
resolving: set[tuple[_ContribInstance, ServiceKey[Any] | EventKey[Any]]] = set()
|
|
107
|
+
|
|
108
|
+
def binding(
|
|
109
|
+
instance: _ContribInstance, key: ServiceKey[Any] | EventKey[Any]
|
|
110
|
+
) -> UUID | None:
|
|
111
|
+
selected = instance, key
|
|
112
|
+
reference = instance.entry.bindings.get(key)
|
|
113
|
+
if reference is None or selected in resolved:
|
|
114
|
+
return instance.bindings.get(key)
|
|
115
|
+
if selected in resolving:
|
|
116
|
+
raise ContribError(
|
|
117
|
+
f"{instance.label}: binding reference cycle for {key.name!r}"
|
|
118
|
+
)
|
|
119
|
+
resolving.add(selected)
|
|
120
|
+
try:
|
|
121
|
+
source = reference._source
|
|
122
|
+
if isinstance(source, tuple):
|
|
123
|
+
providers = declarations.get((instance.enablement, *source), [])
|
|
124
|
+
else:
|
|
125
|
+
if source._hub is not hub:
|
|
126
|
+
raise ContribError(
|
|
127
|
+
f"{instance.label}: binding reference belongs to another hub"
|
|
128
|
+
)
|
|
129
|
+
if isinstance(key, EventKey):
|
|
130
|
+
instance.bindings[key] = source._bindings.get(key)
|
|
131
|
+
resolved.add(selected)
|
|
132
|
+
return instance.bindings[key]
|
|
133
|
+
providers = scoped.get((source, key), [])
|
|
134
|
+
if len(providers) != 1:
|
|
135
|
+
raise ContribError(
|
|
136
|
+
f"{instance.label}: binding reference for {key.name!r} "
|
|
137
|
+
f"requires one provider; found {len(providers)}"
|
|
138
|
+
)
|
|
139
|
+
provider = providers[0]
|
|
140
|
+
if isinstance(key, ServiceKey) and key not in provider.definition.offers:
|
|
141
|
+
raise ContribError(
|
|
142
|
+
f"{instance.label}: {provider.label} does not offer {key.name!r}"
|
|
143
|
+
)
|
|
144
|
+
instance.bindings[key] = binding(provider, key)
|
|
145
|
+
resolved.add(selected)
|
|
146
|
+
return instance.bindings[key]
|
|
147
|
+
finally:
|
|
148
|
+
resolving.remove(selected)
|
|
149
|
+
|
|
150
|
+
errors: dict[_ContribInstance, ContribError] = {}
|
|
151
|
+
for instance in instances:
|
|
152
|
+
for key in instance.entry.bindings:
|
|
153
|
+
try:
|
|
154
|
+
binding(instance, key)
|
|
155
|
+
except ContribError as error:
|
|
156
|
+
errors[instance] = error
|
|
157
|
+
instance.state = Blocked(
|
|
158
|
+
error=error,
|
|
159
|
+
phase="stopped",
|
|
160
|
+
missing=(key.name,) if isinstance(key, ServiceKey) else (),
|
|
161
|
+
)
|
|
162
|
+
if composition.roots is not None:
|
|
163
|
+
# Select concrete providers using the same keys and bindings as startup.
|
|
164
|
+
available: dict[
|
|
165
|
+
tuple[ServiceKey[Any], UUID | None], list[_ContribInstance]
|
|
166
|
+
] = {}
|
|
167
|
+
for instance in instances:
|
|
168
|
+
for key in instance.definition.offers:
|
|
169
|
+
available.setdefault((key, instance.bindings.get(key)), []).append(
|
|
170
|
+
instance
|
|
171
|
+
)
|
|
172
|
+
roots = set(composition.roots)
|
|
173
|
+
known = {entry.name for entry in composition.entries} | {
|
|
174
|
+
record.name for record in composition.diagnostics
|
|
175
|
+
}
|
|
176
|
+
if missing := roots - known:
|
|
177
|
+
raise ContribError(
|
|
178
|
+
f"unknown composition roots: {', '.join(sorted(missing))}"
|
|
179
|
+
)
|
|
180
|
+
selected: set[_ContribInstance] = set()
|
|
181
|
+
pending = [
|
|
182
|
+
instance
|
|
183
|
+
for instance in instances
|
|
184
|
+
if instance.enablement is owner and instance.entry.name in roots
|
|
185
|
+
]
|
|
186
|
+
while pending:
|
|
187
|
+
instance = pending.pop()
|
|
188
|
+
if instance in selected:
|
|
189
|
+
continue
|
|
190
|
+
selected.add(instance)
|
|
191
|
+
if instance.enablement is not owner:
|
|
192
|
+
continue
|
|
193
|
+
pending.extend(instance._parents)
|
|
194
|
+
for key in instance.accepts:
|
|
195
|
+
pending.extend(available.get((key, instance.bindings.get(key)), ()))
|
|
196
|
+
instances = [
|
|
197
|
+
instance
|
|
198
|
+
for instance in instances
|
|
199
|
+
if instance.enablement is not owner or instance in selected
|
|
200
|
+
]
|
|
201
|
+
identities = {instance.registration.id for instance in instances}
|
|
202
|
+
registrations = tuple(
|
|
203
|
+
record
|
|
204
|
+
for record in registrations
|
|
205
|
+
if record.owner is not owner or record.id in identities
|
|
206
|
+
)
|
|
207
|
+
locations = {
|
|
208
|
+
(instance.entry.name, instance.entry.location) for instance in selected
|
|
209
|
+
}
|
|
210
|
+
# Keep invalid declarations reached by explicit bindings even without instances.
|
|
211
|
+
while True:
|
|
212
|
+
referenced = {
|
|
213
|
+
source
|
|
214
|
+
for entry in composition.entries
|
|
215
|
+
if entry.name in roots or (entry.name, entry.location) in locations
|
|
216
|
+
for reference in entry.bindings.values()
|
|
217
|
+
if isinstance(source := reference._source, tuple)
|
|
218
|
+
}
|
|
219
|
+
if referenced <= locations:
|
|
220
|
+
break
|
|
221
|
+
locations.update(referenced)
|
|
222
|
+
diagnostics = [
|
|
223
|
+
record
|
|
224
|
+
for record in diagnostics
|
|
225
|
+
if record.name in roots or (record.name, record.location) in locations
|
|
226
|
+
]
|
|
227
|
+
if not composition.isolate_errors and diagnostics:
|
|
228
|
+
raise ContribError(
|
|
229
|
+
"; ".join(
|
|
230
|
+
f"contrib {record.name!r} at {record.location}: {record.state}"
|
|
231
|
+
for record in diagnostics
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
providers: dict[tuple[ServiceKey[Any], UUID | None], _ContribInstance] = {}
|
|
235
|
+
names: dict[tuple[str, UUID | None], _ContribInstance] = {}
|
|
236
|
+
for instance in instances:
|
|
237
|
+
if (
|
|
238
|
+
instance in errors
|
|
239
|
+
and not composition.isolate_errors
|
|
240
|
+
and instance.registration.id not in unavailable
|
|
241
|
+
):
|
|
242
|
+
raise errors[instance]
|
|
243
|
+
for key in instance.definition.offers:
|
|
244
|
+
identity = instance.bindings.get(key)
|
|
245
|
+
previous = names.get((key.name, identity))
|
|
246
|
+
if previous is not None:
|
|
247
|
+
error = ContribError(
|
|
248
|
+
f"{instance.label}: service {key.name!r} is also provided by "
|
|
249
|
+
f"{previous.label} with the same binding"
|
|
250
|
+
)
|
|
251
|
+
if (
|
|
252
|
+
not composition.isolate_errors
|
|
253
|
+
or previous.enablement is not owner
|
|
254
|
+
or instance.enablement is not owner
|
|
255
|
+
):
|
|
256
|
+
raise error
|
|
257
|
+
previous._record_failure(error)
|
|
258
|
+
instance._record_failure(error)
|
|
259
|
+
providers[key, identity] = instance
|
|
260
|
+
names[key.name, identity] = instance
|
|
261
|
+
for instance in instances:
|
|
262
|
+
for key in instance.accepts:
|
|
263
|
+
provider = providers.get((key, instance.bindings.get(key)))
|
|
264
|
+
if provider is None:
|
|
265
|
+
error = ContribError(
|
|
266
|
+
f"{instance.label}: no contrib provides service {key.name!r} with the required binding"
|
|
267
|
+
)
|
|
268
|
+
if (
|
|
269
|
+
not composition.isolate_errors
|
|
270
|
+
and instance.registration.id not in unavailable
|
|
271
|
+
):
|
|
272
|
+
raise error
|
|
273
|
+
instance.state = Blocked(
|
|
274
|
+
error=error,
|
|
275
|
+
phase="stopped",
|
|
276
|
+
missing=tuple(
|
|
277
|
+
sorted(
|
|
278
|
+
{
|
|
279
|
+
*(
|
|
280
|
+
instance.state.missing
|
|
281
|
+
if isinstance(instance.state, Blocked)
|
|
282
|
+
else ()
|
|
283
|
+
),
|
|
284
|
+
key.name,
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
instance._dependencies[key] = provider
|
|
291
|
+
dependents: dict[_ContribInstance, list[_ContribInstance]] = {}
|
|
292
|
+
for instance in instances:
|
|
293
|
+
for provider in (*instance._dependencies.values(), *instance._parents):
|
|
294
|
+
dependents.setdefault(provider, []).append(instance)
|
|
295
|
+
pending = [
|
|
296
|
+
instance for instance in instances if isinstance(instance.state, Stopping)
|
|
297
|
+
]
|
|
298
|
+
failed = set(pending)
|
|
299
|
+
while pending:
|
|
300
|
+
provider = pending.pop()
|
|
301
|
+
for instance in dependents.get(provider, ()):
|
|
302
|
+
if instance not in failed:
|
|
303
|
+
instance.state = Blocked(
|
|
304
|
+
error=ContribError(
|
|
305
|
+
f"{instance.label}: dependency failed: {provider.label}"
|
|
306
|
+
),
|
|
307
|
+
blocked_by=provider.id,
|
|
308
|
+
phase="stopped",
|
|
309
|
+
missing=provider.state.missing
|
|
310
|
+
if isinstance(provider.state, Blocked)
|
|
311
|
+
else (),
|
|
312
|
+
)
|
|
313
|
+
failed.add(instance)
|
|
314
|
+
pending.append(instance)
|
|
315
|
+
elif isinstance(instance.state, Blocked) and isinstance(
|
|
316
|
+
provider.state, Blocked
|
|
317
|
+
):
|
|
318
|
+
missing = tuple(
|
|
319
|
+
sorted(set(instance.state.missing) | set(provider.state.missing))
|
|
320
|
+
)
|
|
321
|
+
if missing != instance.state.missing:
|
|
322
|
+
instance.state = replace(instance.state, missing=missing)
|
|
323
|
+
pending.append(instance)
|
|
324
|
+
active = [instance for instance in instances if instance not in failed]
|
|
325
|
+
try:
|
|
326
|
+
ordered = sort(active)
|
|
327
|
+
except _CycleError as error:
|
|
328
|
+
if not composition.isolate_errors and any(
|
|
329
|
+
instance.registration.id not in unavailable for instance in error.instances
|
|
330
|
+
):
|
|
331
|
+
raise
|
|
332
|
+
for instance in error.instances:
|
|
333
|
+
instance.state = Blocked(error=error, phase="stopped")
|
|
334
|
+
failed.update(error.instances)
|
|
335
|
+
ordered = sort([instance for instance in active if instance not in failed])
|
|
336
|
+
for instance in instances:
|
|
337
|
+
if instance in failed and instance.enablement is owner:
|
|
338
|
+
instance._transition("stopped")
|
|
339
|
+
diagnostics.append(instance.status)
|
|
340
|
+
return Candidate(
|
|
341
|
+
ordered, hub, hub._revision, tuple(registrations), owner, tuple(diagnostics)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class _CycleError(ContribError):
|
|
346
|
+
def __init__(self, message: str, instances: Sequence[_ContribInstance]) -> None:
|
|
347
|
+
super().__init__(message)
|
|
348
|
+
self.instances = tuple(instances)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def sort(
|
|
352
|
+
instances: Sequence[_ContribInstance],
|
|
353
|
+
*,
|
|
354
|
+
dependencies: dict[_ContribInstance, set[_ContribInstance]] | None = None,
|
|
355
|
+
) -> tuple[_ContribInstance, ...]:
|
|
356
|
+
"""Order providers and parents first, or validate explicit waiting edges."""
|
|
357
|
+
pending = {
|
|
358
|
+
instance: order for order, instance in enumerate(dict.fromkeys(instances))
|
|
359
|
+
}
|
|
360
|
+
if dependencies is None:
|
|
361
|
+
dependencies = {
|
|
362
|
+
instance: {
|
|
363
|
+
*instance._dependencies.values(),
|
|
364
|
+
*instance._parents,
|
|
365
|
+
}.intersection(pending)
|
|
366
|
+
for instance in pending
|
|
367
|
+
}
|
|
368
|
+
else:
|
|
369
|
+
dependencies = {
|
|
370
|
+
instance: dependencies[instance].intersection(pending)
|
|
371
|
+
for instance in pending
|
|
372
|
+
}
|
|
373
|
+
dependents: dict[_ContribInstance, list[_ContribInstance]] = {}
|
|
374
|
+
for instance, providers in dependencies.items():
|
|
375
|
+
for provider in providers:
|
|
376
|
+
dependents.setdefault(provider, []).append(instance)
|
|
377
|
+
ready = [
|
|
378
|
+
(instance._order, order, instance)
|
|
379
|
+
for instance, order in pending.items()
|
|
380
|
+
if not dependencies[instance]
|
|
381
|
+
]
|
|
382
|
+
heapify(ready)
|
|
383
|
+
result: list[_ContribInstance] = []
|
|
384
|
+
while ready:
|
|
385
|
+
_, _, instance = heappop(ready)
|
|
386
|
+
del pending[instance]
|
|
387
|
+
result.append(instance)
|
|
388
|
+
for dependent in dependents.get(instance, ()):
|
|
389
|
+
dependencies[dependent].remove(instance)
|
|
390
|
+
if not dependencies[dependent]:
|
|
391
|
+
heappush(ready, (dependent._order, pending[dependent], dependent))
|
|
392
|
+
if pending:
|
|
393
|
+
details = [
|
|
394
|
+
f"{instance.label} waits for {provider.label}"
|
|
395
|
+
for instance in pending
|
|
396
|
+
for provider in dependencies[instance]
|
|
397
|
+
]
|
|
398
|
+
raise _CycleError(
|
|
399
|
+
f"contrib dependency cycle prevents startup: {'; '.join(details)}",
|
|
400
|
+
tuple(pending),
|
|
401
|
+
)
|
|
402
|
+
return tuple(result)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@dataclass(frozen=True, slots=True)
|
|
406
|
+
class _Diff:
|
|
407
|
+
registrations: tuple[_Registration, ...]
|
|
408
|
+
closed: tuple[Enablement, ...]
|
|
409
|
+
affected: frozenset[_ContribInstance]
|
|
410
|
+
retained: dict[UUID, _ContribInstance]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def target(hub: ContribHub, owner: Enablement, composition: Composition) -> Candidate:
|
|
414
|
+
"""Validate the complete instance graph, reusing matching registrations."""
|
|
415
|
+
if not isinstance(hub._state, _Open) or not isinstance(owner._state, _Open):
|
|
416
|
+
raise ContribError("the hub or composition is stopping")
|
|
417
|
+
composition = Composition.model_validate(composition)
|
|
418
|
+
remaining = list(owner._registrations.values())
|
|
419
|
+
records = []
|
|
420
|
+
diagnostics = [
|
|
421
|
+
replace(
|
|
422
|
+
record,
|
|
423
|
+
id=uuid7(),
|
|
424
|
+
parent=owner._parent.id if owner._parent is not None else None,
|
|
425
|
+
)
|
|
426
|
+
for record in composition.diagnostics
|
|
427
|
+
]
|
|
428
|
+
context = composition.model_copy(
|
|
429
|
+
update={
|
|
430
|
+
"entries": (
|
|
431
|
+
*(
|
|
432
|
+
record.entry
|
|
433
|
+
for record in hub._registrations.values()
|
|
434
|
+
if record.owner is not owner
|
|
435
|
+
),
|
|
436
|
+
*composition.entries,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
for entry in composition.entries:
|
|
441
|
+
try:
|
|
442
|
+
config = entry.definition.validate_config(entry.config, context=context)
|
|
443
|
+
except ValueError as error:
|
|
444
|
+
if not composition.isolate_errors and composition.roots is None:
|
|
445
|
+
raise ContribError(
|
|
446
|
+
f"contrib {entry.name!r} at {entry.location}: invalid configuration: {error}"
|
|
447
|
+
) from error
|
|
448
|
+
diagnostics.append(
|
|
449
|
+
ContribStatus(
|
|
450
|
+
uuid7(),
|
|
451
|
+
entry.name,
|
|
452
|
+
entry.location,
|
|
453
|
+
Failed(error=str(error), phase="stopped"),
|
|
454
|
+
parent=owner._parent.id if owner._parent is not None else None,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
continue
|
|
458
|
+
current = next(
|
|
459
|
+
(
|
|
460
|
+
record
|
|
461
|
+
for record in remaining
|
|
462
|
+
if record.entry.name == entry.name
|
|
463
|
+
and record.entry.definition == entry.definition
|
|
464
|
+
and record.config == config
|
|
465
|
+
and record.entry.local == entry.local
|
|
466
|
+
and record.entry.bindings == entry.bindings
|
|
467
|
+
and tuple(record.entry.required_offers) == tuple(entry.required_offers)
|
|
468
|
+
and record.entry.source == entry.source
|
|
469
|
+
),
|
|
470
|
+
None,
|
|
471
|
+
)
|
|
472
|
+
if current is None:
|
|
473
|
+
records.append(_Registration(entry, config, owner))
|
|
474
|
+
else:
|
|
475
|
+
remaining.remove(current)
|
|
476
|
+
records.append(replace(current, entry=entry, config=config))
|
|
477
|
+
removed = {record.id for record in remaining}
|
|
478
|
+
closed: set[Enablement] = set()
|
|
479
|
+
while True:
|
|
480
|
+
added = {
|
|
481
|
+
group
|
|
482
|
+
for group in hub._owners.values()
|
|
483
|
+
if group is not owner
|
|
484
|
+
and group not in closed
|
|
485
|
+
and group._parent is not None
|
|
486
|
+
and (
|
|
487
|
+
group._parent.registration.id in removed
|
|
488
|
+
or group._parent.enablement in closed
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
if not added:
|
|
492
|
+
break
|
|
493
|
+
closed.update(added)
|
|
494
|
+
removed.update(
|
|
495
|
+
record.id for group in added for record in group._registrations.values()
|
|
496
|
+
)
|
|
497
|
+
outside = [
|
|
498
|
+
record
|
|
499
|
+
for record in hub._registrations.values()
|
|
500
|
+
if record.owner is not owner and record.owner not in closed
|
|
501
|
+
]
|
|
502
|
+
while True:
|
|
503
|
+
preview = validate(
|
|
504
|
+
hub,
|
|
505
|
+
owner,
|
|
506
|
+
composition.model_copy(update={"diagnostics": tuple(diagnostics)}),
|
|
507
|
+
(*outside, *records),
|
|
508
|
+
)
|
|
509
|
+
changes = diff(hub, owner, preview)
|
|
510
|
+
added = {
|
|
511
|
+
group
|
|
512
|
+
for group in hub._owners.values()
|
|
513
|
+
if group is not owner
|
|
514
|
+
and group not in closed
|
|
515
|
+
and group._parent in changes.affected
|
|
516
|
+
}
|
|
517
|
+
if not added:
|
|
518
|
+
return preview
|
|
519
|
+
closed.update(added)
|
|
520
|
+
outside = [record for record in outside if record.owner not in closed]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def diff(hub: ContribHub, owner: Enablement, preview: Candidate) -> _Diff:
|
|
524
|
+
"""Find unchanged instances and those that must stop or restart after an update."""
|
|
525
|
+
fresh = {instance.registration.id: instance for instance in preview._instances}
|
|
526
|
+
current = {instance.registration.id: instance for instance in hub._all_instances()}
|
|
527
|
+
kept: dict[UUID, _ContribInstance] = {}
|
|
528
|
+
for id, instance in fresh.items():
|
|
529
|
+
previous = current.get(id)
|
|
530
|
+
if (
|
|
531
|
+
previous is not None
|
|
532
|
+
and (
|
|
533
|
+
previous.enablement is not owner
|
|
534
|
+
or not isinstance(previous.state, Stopping)
|
|
535
|
+
)
|
|
536
|
+
and previous.bindings == instance.bindings
|
|
537
|
+
and previous.accepts == instance.accepts
|
|
538
|
+
and previous._dependencies.keys() == instance._dependencies.keys()
|
|
539
|
+
and all(
|
|
540
|
+
previous._dependencies[key].registration.id == provider.registration.id
|
|
541
|
+
for key, provider in instance._dependencies.items()
|
|
542
|
+
)
|
|
543
|
+
):
|
|
544
|
+
kept[id] = previous
|
|
545
|
+
affected = {
|
|
546
|
+
instance for id, instance in current.items() if kept.get(id) is not instance
|
|
547
|
+
}
|
|
548
|
+
pending = list(affected)
|
|
549
|
+
while pending:
|
|
550
|
+
for instance in hub._dependents.get(pending.pop(), {}).values():
|
|
551
|
+
if instance not in affected:
|
|
552
|
+
affected.add(instance)
|
|
553
|
+
pending.append(instance)
|
|
554
|
+
kept = {id: instance for id, instance in kept.items() if instance not in affected}
|
|
555
|
+
records = {record.id for record in preview._registrations}
|
|
556
|
+
removed = tuple(
|
|
557
|
+
record for record in hub._registrations.values() if record.id not in records
|
|
558
|
+
)
|
|
559
|
+
closed = tuple(
|
|
560
|
+
group
|
|
561
|
+
for group in hub._owners.values()
|
|
562
|
+
if group is not owner and group._parent in affected
|
|
563
|
+
)
|
|
564
|
+
return _Diff(removed, closed, frozenset(affected), kept)
|