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.
@@ -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
+ ]
@@ -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)