topkit 0.2.0a3__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.
TopKit/overlay.py ADDED
@@ -0,0 +1,791 @@
1
+ """Overlay: installing one Tag's declarations into a candidate state.
2
+
3
+ The latest applied Layer is the visible Overlay for a name. Within one
4
+ scope a name is one slot: an Action or a Record, never both at once.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import wraps
10
+ from typing import Any
11
+ from typing import Callable
12
+ import warnings
13
+
14
+ from .contracts import _bind_condition
15
+ from .declarations import _Declarations
16
+ from .declarations import _is_flag
17
+ from .declarations import _parameters_of
18
+ from .declarations import _protocol_inputs
19
+ from .declarations import _takes_stored
20
+ from .declarations import _takes_underlay
21
+ from .errors import TagCompositionError
22
+ from .errors import TagDeclarationError
23
+ from .errors import TagError
24
+ from .errors import TagOverwriteWarning
25
+ from .errors import TagContractWarning
26
+ from .errors import TagResolutionError
27
+ from .declarations import STATE
28
+ from .declarations import Report
29
+ from .declarations import _declarations_of
30
+ from .geometry import _related
31
+ from .declarations import _MISSING
32
+ from .state import _Bound
33
+ from .state import _Pinned_Operation
34
+ from .state import _State
35
+ from .state import _namespace_of
36
+ from .state import _state_of
37
+
38
+
39
+ Function = Callable[..., Any]
40
+
41
+
42
+ # ------------------------------------------------------------------
43
+ # Helpers
44
+ # ------------------------------------------------------------------
45
+
46
+
47
+ def _is_tag_type(
48
+ candidate: object,
49
+ ) -> bool:
50
+ from .tags import MetaTag
51
+
52
+ return isinstance(candidate, MetaTag)
53
+
54
+
55
+ def _independent(
56
+ tag: type,
57
+ origin: type,
58
+ ) -> bool:
59
+ """True when ``origin`` is a Tag unrelated to ``tag`` (not in its Form)."""
60
+
61
+ return (
62
+ _is_tag_type(origin)
63
+ and not _related(tag, origin)
64
+ )
65
+
66
+
67
+ def _host_function(
68
+ host_type: type,
69
+ name: str,
70
+ ) -> Function | None:
71
+ """A plain callable the host class defines under ``name``, wrapped so it
72
+ can serve as an Underlay."""
73
+
74
+ for klass in host_type.__mro__:
75
+ attribute = klass.__dict__.get(name)
76
+
77
+ if attribute is None:
78
+ continue
79
+
80
+ if isinstance(attribute, (classmethod, staticmethod)):
81
+ return None
82
+
83
+ if not callable(attribute):
84
+ return None
85
+
86
+ @wraps(attribute)
87
+ def Host_Action(
88
+ agent: object,
89
+ *args: Any,
90
+ **kwargs: Any,
91
+ ) -> Any:
92
+ return attribute(
93
+ agent,
94
+ *args,
95
+ **kwargs,
96
+ )
97
+
98
+ return Host_Action
99
+
100
+ return None
101
+
102
+
103
+ def _host_data_descriptor(
104
+ host_type: type,
105
+ name: str,
106
+ ) -> bool:
107
+ for klass in host_type.__mro__:
108
+ attribute = klass.__dict__.get(name)
109
+
110
+ if attribute is not None:
111
+ return hasattr(
112
+ type(attribute),
113
+ "__set__",
114
+ )
115
+
116
+ return False
117
+
118
+
119
+ def _compose(
120
+ function: Function,
121
+ underlay: Function | None,
122
+ ) -> Function:
123
+ """Bind an Action to its Underlay (when it asks for one)."""
124
+
125
+ uses_underlay = _takes_underlay(function)
126
+
127
+ if not uses_underlay:
128
+ return function
129
+
130
+ if underlay is None:
131
+ raise TagResolutionError(
132
+ f"{function.__qualname__} requires a visible Underlay"
133
+ )
134
+
135
+ @wraps(function)
136
+ def Call(
137
+ agent: object,
138
+ *args: Any,
139
+ **kwargs: Any,
140
+ ) -> Any:
141
+ def prior(
142
+ *next_args: Any,
143
+ **next_kwargs: Any,
144
+ ) -> Any:
145
+ if next_args or next_kwargs:
146
+ return underlay(
147
+ agent,
148
+ *next_args,
149
+ **next_kwargs,
150
+ )
151
+
152
+ return underlay(
153
+ agent,
154
+ *args,
155
+ **kwargs,
156
+ )
157
+
158
+ return function(
159
+ agent,
160
+ prior,
161
+ *args,
162
+ **kwargs,
163
+ )
164
+
165
+ return Call
166
+
167
+
168
+ def _adapter(
169
+ tag: type,
170
+ name: str,
171
+ operation: Function,
172
+ ) -> Function:
173
+ """The Action a Public Operation publishes: the Agent is passed to the
174
+ Operation as its second input. A published member answers members
175
+ only, so a Rogue Agent's call fails closed (STEP-SPEC-10)."""
176
+
177
+ def Published(
178
+ agent: object,
179
+ *args: Any,
180
+ **kwargs: Any,
181
+ ) -> Any:
182
+ _require_membership(
183
+ agent,
184
+ tag,
185
+ name,
186
+ "Operation",
187
+ )
188
+
189
+ return operation(
190
+ tag,
191
+ agent,
192
+ *args,
193
+ **kwargs,
194
+ )
195
+
196
+ Published.__name__ = name
197
+ Published.__qualname__ = f"{tag.__qualname__}.{name}"
198
+ Published.__doc__ = operation.__doc__
199
+
200
+ return Published
201
+
202
+
203
+ def _require_membership(
204
+ agent: object,
205
+ tag: type,
206
+ name: str,
207
+ kind: str,
208
+ ) -> None:
209
+ """A published member answers members only, and only sound ones
210
+ (STEP-SPEC-10): the Agent must still belong to the publishing Tag, and
211
+ every promise on the Agent must hold. A Rogue Agent raises a Rogue
212
+ Access Failure; a defective one raises the broken promise by name."""
213
+
214
+ from .contracts import _guarded
215
+ from .errors import TagPostconditionError
216
+ from .errors import TagRogueAccessError
217
+ from .state import _name_of
218
+ from .state import _state_of
219
+
220
+ state = _state_of(agent)
221
+
222
+ if state is None or tag not in state.active:
223
+ raise TagRogueAccessError(
224
+ f"{name!r} is a published {kind} of {tag.__name__};"
225
+ f" {_name_of(agent)} is a Rogue Agent of that Tag, and a"
226
+ " published member answers members only"
227
+ )
228
+
229
+ if state.postconditions:
230
+ _guarded(
231
+ agent,
232
+ "postconditions",
233
+ True,
234
+ TagPostconditionError,
235
+ "Postcondition",
236
+ )
237
+
238
+
239
+ # ------------------------------------------------------------------
240
+ # Installing
241
+ # ------------------------------------------------------------------
242
+
243
+
244
+ def _install(
245
+ state: _State,
246
+ tag: type,
247
+ declarations: _Declarations,
248
+ ) -> None:
249
+ """Lay ``tag`` over ``state`` (a candidate copy). Order matters: deletions
250
+ free names first; conditions, Tag members, Actions, Records follow."""
251
+
252
+ if _is_flag(tag):
253
+ _refuse_container_host(
254
+ state,
255
+ tag,
256
+ )
257
+
258
+ for name in declarations.deletions:
259
+ _delete(state, name)
260
+
261
+ for name, function in declarations.preconditions:
262
+ prior = state.preconditions.get(name)
263
+ state.preconditions[name] = _stamp(
264
+ _bind_condition(
265
+ function,
266
+ prior,
267
+ True,
268
+ ),
269
+ tag,
270
+ )
271
+
272
+ for name, function in declarations.postconditions:
273
+ prior = state.postconditions.get(name)
274
+
275
+ if (
276
+ prior is not None
277
+ and not _takes_underlay(function)
278
+ and _origin_of(prior) is not tag
279
+ ):
280
+ warnings.warn(
281
+ f"{tag.__name__}.{name} overrides a Base Postcondition"
282
+ " without @Underlay (weakens a promise; see Forward-Post)",
283
+ TagContractWarning,
284
+ stacklevel=6,
285
+ )
286
+
287
+ state.postconditions[name] = _stamp(
288
+ _bind_condition(
289
+ function,
290
+ prior,
291
+ False,
292
+ ),
293
+ tag,
294
+ )
295
+
296
+ for name, report, public in declarations.reports:
297
+ state.reports[name] = (tag, report)
298
+
299
+ if public:
300
+ state.published.add(name)
301
+
302
+ for name, operation, public in declarations.operations:
303
+ state.operations[name] = (tag, operation)
304
+
305
+ if public:
306
+ _install_action(
307
+ state,
308
+ tag,
309
+ name,
310
+ _adapter(tag, name, operation),
311
+ )
312
+
313
+ for name, function in declarations.actions:
314
+ _install_action(
315
+ state,
316
+ tag,
317
+ name,
318
+ function,
319
+ )
320
+
321
+ for name, builder in declarations.records:
322
+ _install_record(
323
+ state,
324
+ tag,
325
+ name,
326
+ builder,
327
+ )
328
+
329
+ state.secrets.update(declarations.secrets)
330
+
331
+ if state.pinned is not None:
332
+ state.published.update(declarations.published)
333
+
334
+ if declarations.rips:
335
+ state.rips[tag] = tuple(
336
+ state.actions[name]
337
+ for name in declarations.rips
338
+ )
339
+
340
+
341
+ def _refuse_stored_input_collision(
342
+ builder: Function,
343
+ inputs: dict[str, Any],
344
+ ) -> None:
345
+ """A Record's second positional parameter is the stored value. If it is
346
+ named like a supplied input, the author almost certainly meant the
347
+ input; say so rather than hand over the stored value in silence."""
348
+
349
+ second = _parameters_of(builder).named[1][0]
350
+
351
+ if second in inputs:
352
+ raise TagDeclarationError(
353
+ f"{builder.__qualname__}: its second parameter {second!r} is"
354
+ f" the stored value, but an input named {second!r} was"
355
+ " supplied. Take the input by name after a `*`:"
356
+ f" `def {builder.__name__}(agent, *, {second})`, or rename"
357
+ " the stored parameter."
358
+ )
359
+
360
+
361
+ def _refuse_container_host(
362
+ state: _State,
363
+ tag: type,
364
+ ) -> None:
365
+ from .access import _host_member
366
+
367
+ if state.pinned is not None:
368
+ return # on a Tag, TOP owns `in`: a string in it asks for a keyword
369
+
370
+ if _host_member(state.host_type, "__contains__") is not None:
371
+ raise TagCompositionError(
372
+ f"{tag.__name__} is a Flag, but the host"
373
+ f" {state.host_type.__name__} defines its own `in`; a"
374
+ " keyword needs that seat empty"
375
+ )
376
+
377
+
378
+ def _stamp(
379
+ check: Function,
380
+ tag: type,
381
+ ) -> Function:
382
+ """Remember which Tag bound a condition, so a Tag re-applied after a
383
+ Rip replaces its own promise silently (§0.7). Conditions are sticky:
384
+ Rip never touches them; the author ends them (STEP-SPEC-12)."""
385
+
386
+ check.__topkit_origin__ = tag # type: ignore[attr-defined]
387
+
388
+ return check
389
+
390
+
391
+ def _origin_of(
392
+ check: Function,
393
+ ) -> type | None:
394
+ return getattr(
395
+ check,
396
+ "__topkit_origin__",
397
+ None,
398
+ )
399
+
400
+
401
+ def _own_declaration(
402
+ pinned: type,
403
+ name: str,
404
+ ) -> tuple[type, str, Any] | None:
405
+ """Where the pinned Tag itself declares ``name``, as what, and the
406
+ declared object: ("operation" | "report" | "value" | "agent" |
407
+ "protocol"). None when the name is absent or TOP-managed (landed by a
408
+ Pin), in which case the Overlay laws decide."""
409
+
410
+ for klass in pinned.__mro__:
411
+ if name not in klass.__dict__:
412
+ continue
413
+
414
+ managed = klass.__dict__.get(STATE)
415
+
416
+ if managed is not None and (
417
+ name in managed.actions
418
+ or name in managed.records
419
+ ):
420
+ return None
421
+
422
+ declared = klass.__dict__[name]
423
+
424
+ if not hasattr(klass, "_topkit_field"):
425
+ return (klass, "protocol", declared)
426
+
427
+ declarations = _declarations_of(klass)
428
+
429
+ if any(name == n for n, _f, _p in declarations.operations):
430
+ return (klass, "operation", declared)
431
+
432
+ if any(name == n for n, _r, _p in declarations.reports):
433
+ return (klass, "report", declared)
434
+
435
+ if any(name == n for n, _f in declarations.actions) or any(
436
+ name == n for n, _f in declarations.records
437
+ ):
438
+ return (klass, "agent", declared)
439
+
440
+ if (
441
+ any(name == n for n, _f in declarations.preconditions)
442
+ or any(name == n for n, _f in declarations.postconditions)
443
+ or any(name == n for n, _f in declarations.imprints)
444
+ or name in declarations.deletions
445
+ ):
446
+ return (klass, "protocol", declared)
447
+
448
+ return (klass, "value", declared)
449
+
450
+ return None
451
+
452
+
453
+ def _refuse_tag_member(
454
+ state: _State,
455
+ tag: type,
456
+ name: str,
457
+ ) -> None:
458
+ """Collision control for a Pin (STEP-SPEC-9 §4). A Pin's members are
459
+ Tag scope, so they may overlay what the Tag declares in Tag scope (an
460
+ Operation, a Report, a plain value) as a host member: silently, with
461
+ the declaration as Underlay or stored value. They may never take a
462
+ name the Tag declares in Agent scope or as a protocol: on a class the
463
+ two scopes share one dictionary, and the write would silently remove
464
+ the declaration from every future Agent's contract. Nor a name every
465
+ Tag answers through its metaclass."""
466
+
467
+ pinned = state.pinned
468
+
469
+ if hasattr(type(pinned), name):
470
+ raise TagCompositionError(
471
+ f"{tag.__name__}.{name}: a Pin may not name what every Tag"
472
+ f" already answers ({name!r} belongs to the Tag's metaclass)"
473
+ )
474
+
475
+ found = _own_declaration(
476
+ pinned,
477
+ name,
478
+ )
479
+
480
+ if found is None:
481
+ return
482
+
483
+ klass, kind, declared = found
484
+
485
+ if kind in ("operation", "report", "value"):
486
+ state.originals.setdefault(
487
+ name,
488
+ declared,
489
+ ) # the first patch remembers the declaration
490
+ return
491
+
492
+ where = (
493
+ "itself"
494
+ if klass is pinned
495
+ else f"in its Base {klass.__name__}"
496
+ )
497
+ what = (
498
+ "an Agent-scope member"
499
+ if kind == "agent"
500
+ else "a protocol"
501
+ )
502
+
503
+ raise TagCompositionError(
504
+ f"{tag.__name__}.{name}: {pinned.__name__} declares {name!r}"
505
+ f" {where} as {what}; a Pin overlays a Tag's Operations and"
506
+ " Reports, never its Agent members or protocols (STEP-SPEC-9 §4)"
507
+ )
508
+
509
+
510
+ def _pinned_host_function(
511
+ pinned: type,
512
+ name: str,
513
+ ) -> Function | None:
514
+ """The pinned Tag's own Operation under ``name``, as the Underlay of a
515
+ Pin Action: the patch can call the engine it replaces."""
516
+
517
+ found = _own_declaration(
518
+ pinned,
519
+ name,
520
+ )
521
+
522
+ if found is None or found[1] != "operation":
523
+ return None
524
+
525
+ operation = found[2].__func__
526
+
527
+ @wraps(operation)
528
+ def Host_Operation(
529
+ tag: type,
530
+ *args: Any,
531
+ **kwargs: Any,
532
+ ) -> Any:
533
+ return operation(
534
+ tag,
535
+ *args,
536
+ **kwargs,
537
+ )
538
+
539
+ return Host_Operation
540
+
541
+
542
+ def _pinned_stored(
543
+ pinned: type,
544
+ name: str,
545
+ ) -> Any:
546
+ """What the pinned Tag currently holds under ``name``, for a Pin
547
+ Record's stored seat: its own Report's value, a plain value, or what an
548
+ earlier Pin landed (own or inherited). Behaviour is never a stored
549
+ value."""
550
+
551
+ for klass in pinned.__mro__:
552
+ if name not in klass.__dict__:
553
+ continue
554
+
555
+ declared = klass.__dict__[name]
556
+
557
+ if isinstance(declared, Report):
558
+ return declared.__get__(
559
+ None,
560
+ pinned,
561
+ )
562
+
563
+ if isinstance(
564
+ declared,
565
+ (
566
+ _Bound,
567
+ _Pinned_Operation,
568
+ classmethod,
569
+ staticmethod,
570
+ ),
571
+ ) or callable(declared):
572
+ return None
573
+
574
+ return declared
575
+
576
+ return None
577
+
578
+
579
+ def _delete(
580
+ state: _State,
581
+ name: str,
582
+ ) -> None:
583
+ state.actions.pop(name, None)
584
+ state.action_origins.pop(name, None)
585
+ state.records.pop(name, None)
586
+ state.preconditions.pop(name, None)
587
+ state.postconditions.pop(name, None)
588
+ state.reports.pop(name, None)
589
+ state.operations.pop(name, None)
590
+ state.published.discard(name)
591
+ state.secrets.discard(name)
592
+ state.deleted.add(name)
593
+
594
+
595
+ def _install_action(
596
+ state: _State,
597
+ tag: type,
598
+ name: str,
599
+ function: Function,
600
+ ) -> None:
601
+ if state.pinned is not None:
602
+ _refuse_tag_member(
603
+ state,
604
+ tag,
605
+ name,
606
+ )
607
+
608
+ record_origin = state.records.get(name)
609
+
610
+ if record_origin is not None:
611
+ if _independent(tag, record_origin):
612
+ raise TagCompositionError(
613
+ f"{tag.__name__}.{name} is an Action but"
614
+ f" {record_origin.__name__} already contributes a Record"
615
+ " of that name; independent Tags cannot share one Agent"
616
+ " name across kinds"
617
+ )
618
+
619
+ state.records.pop(name)
620
+
621
+ underlay = state.actions.get(name)
622
+ origin = state.action_origins.get(name, state.host_type)
623
+
624
+ if underlay is None and name not in state.deleted:
625
+ if state.pinned is not None:
626
+ underlay = _pinned_host_function(
627
+ state.pinned,
628
+ name,
629
+ )
630
+ else:
631
+ underlay = _host_function(
632
+ state.host_type,
633
+ name,
634
+ )
635
+
636
+ if (
637
+ underlay is not None
638
+ and not _takes_underlay(function)
639
+ and _independent(tag, origin)
640
+ ):
641
+ warnings.warn(
642
+ f"{tag.__name__}.{name} replaces the Action of independent"
643
+ f" Tag {origin.__name__}",
644
+ TagOverwriteWarning,
645
+ stacklevel=6,
646
+ )
647
+
648
+ state.actions[name] = _compose(
649
+ function,
650
+ underlay,
651
+ )
652
+ state.action_origins[name] = tag
653
+ state.deleted.discard(name)
654
+ state.published.discard(name)
655
+
656
+
657
+ def _install_record(
658
+ state: _State,
659
+ tag: type,
660
+ name: str,
661
+ builder: Function,
662
+ ) -> None:
663
+ if state.pinned is not None:
664
+ _refuse_tag_member(
665
+ state,
666
+ tag,
667
+ name,
668
+ )
669
+
670
+ if _host_data_descriptor(state.host_type, name):
671
+ raise TagCompositionError(
672
+ f"{tag.__name__}.{name} is a Record but the host"
673
+ f" {state.host_type.__name__} defines {name!r} as a property"
674
+ " or slot; Records need an ordinary attribute"
675
+ )
676
+
677
+ action_origin = state.action_origins.get(name)
678
+
679
+ if action_origin is not None:
680
+ if _independent(tag, action_origin):
681
+ raise TagCompositionError(
682
+ f"{tag.__name__}.{name} is a Record but"
683
+ f" {action_origin.__name__} already contributes an"
684
+ " Action of that name; independent Tags cannot share"
685
+ " one Agent name across kinds"
686
+ )
687
+
688
+ state.actions.pop(name)
689
+ state.action_origins.pop(name)
690
+
691
+ prior = state.records.get(name)
692
+
693
+ if (
694
+ prior is not None
695
+ and not _takes_stored(builder)
696
+ and _independent(tag, prior)
697
+ ):
698
+ warnings.warn(
699
+ f"{tag.__name__}.{name} replaces the Record of independent"
700
+ f" Tag {prior.__name__}",
701
+ TagOverwriteWarning,
702
+ stacklevel=6,
703
+ )
704
+
705
+ state.records[name] = tag
706
+ state.deleted.discard(name)
707
+ state.published.discard(name)
708
+
709
+
710
+ # ------------------------------------------------------------------
711
+ # Materializing Records
712
+ # ------------------------------------------------------------------
713
+
714
+
715
+ def _materialize(
716
+ agent: object,
717
+ declarations: _Declarations,
718
+ deleted_before: set[str],
719
+ inputs: dict[str, Any],
720
+ ) -> None:
721
+ """Run the Tag's Record builders and store their values on the Agent.
722
+
723
+ The builder's optional second positional input is the value already
724
+ stored under that name, or None when there is none (or the name was
725
+ deleted). Application inputs bind by name to the parameters after
726
+ that, so ``def code(agent, *, code)`` stores the input directly.
727
+ """
728
+
729
+ namespace = _namespace_of(agent)
730
+ pinned = isinstance(agent, type)
731
+
732
+ for name, builder in declarations.records:
733
+ if pinned:
734
+ stored = _state_of(agent).secret_values.get(name, _MISSING)
735
+
736
+ if stored is _MISSING:
737
+ stored = _pinned_stored(
738
+ agent,
739
+ name,
740
+ )
741
+ else:
742
+ stored = namespace.get(name)
743
+
744
+ if name in deleted_before or isinstance(
745
+ stored,
746
+ (
747
+ _Bound,
748
+ _Pinned_Operation,
749
+ ),
750
+ ):
751
+ stored = None
752
+
753
+ takes_stored = _takes_stored(builder)
754
+
755
+ if takes_stored:
756
+ _refuse_stored_input_collision(
757
+ builder,
758
+ inputs,
759
+ )
760
+
761
+ named = _protocol_inputs(
762
+ builder,
763
+ inputs,
764
+ 2 if takes_stored else 1,
765
+ )
766
+
767
+ try:
768
+ if takes_stored:
769
+ value = builder(
770
+ agent,
771
+ stored,
772
+ **named,
773
+ )
774
+ else:
775
+ value = builder(
776
+ agent,
777
+ **named,
778
+ )
779
+ except TagError:
780
+ raise
781
+ except Exception as error:
782
+ raise TagCompositionError(
783
+ f"Record {builder.__qualname__} could not be"
784
+ f" materialized: {type(error).__name__}: {error}"
785
+ ) from error
786
+
787
+ if pinned and name in declarations.secrets:
788
+ namespace.pop(name, None)
789
+ _state_of(agent).secret_values[name] = value
790
+ else:
791
+ namespace[name] = value