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/declarations.py ADDED
@@ -0,0 +1,1010 @@
1
+ """Declarations: the marks an author puts on a Tag, and how they are read.
2
+
3
+ Agent scope: @Action, @Record (external by default, @Secret hides)
4
+ Tag scope: @Operation, @Report (internal by default, @Public publishes)
5
+ Protocols: @Imprint, @Pre, @Post, @Rip, @Delete
6
+ Composition: @Underlay (extend the prior visible contribution)
7
+
8
+ A Tag class is scanned once; the result is cached per class.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from inspect import Parameter
15
+ from inspect import signature
16
+ from typing import Any
17
+ from typing import Callable
18
+ from weakref import WeakKeyDictionary
19
+
20
+ from .errors import TagDeclarationError
21
+ from .errors import TagImprintError
22
+ from .errors import TagPostconditionError
23
+ from .errors import TagPreconditionError
24
+
25
+
26
+ _KIND = "__topkit_kind__"
27
+ _UNDERLAY = "__topkit_underlay__"
28
+ _RIP = "__topkit_rip__"
29
+ _SECRET = "__topkit_secret__"
30
+ _PUBLIC = "__topkit_public__"
31
+ _FLAG = "__topkit_flag__"
32
+ _PIN = "__topkit_pin__"
33
+
34
+ STATE = "_TOPKIT_STATE"
35
+
36
+ _MISSING = object()
37
+
38
+ Function = Callable[..., Any]
39
+
40
+
41
+ # ------------------------------------------------------------------
42
+ # Marks
43
+ # ------------------------------------------------------------------
44
+
45
+
46
+ _CONDITION_KINDS = ("precondition", "postcondition", "condition")
47
+
48
+
49
+ def _mark(
50
+ function: Function,
51
+ kind: str,
52
+ ) -> Function:
53
+ """Mark a function with its kind. ``@Pre`` and ``@Post`` stacked on one
54
+ function make it a *condition*: necessary to enter and to stay."""
55
+
56
+ existing = getattr(
57
+ function,
58
+ _KIND,
59
+ None,
60
+ )
61
+
62
+ if (
63
+ existing is not None
64
+ and existing != kind
65
+ and existing in _CONDITION_KINDS
66
+ and kind in _CONDITION_KINDS
67
+ ):
68
+ kind = "condition"
69
+
70
+ setattr(
71
+ function,
72
+ _KIND,
73
+ kind,
74
+ )
75
+
76
+ return function
77
+
78
+
79
+ def _flag(
80
+ target: Any,
81
+ name: str,
82
+ ) -> Any:
83
+ if isinstance(target, Report):
84
+ function = target.builder
85
+ else:
86
+ function = getattr(
87
+ target,
88
+ "__func__",
89
+ target,
90
+ )
91
+ setattr(
92
+ function,
93
+ name,
94
+ True,
95
+ )
96
+
97
+ return target
98
+
99
+
100
+ def Action(
101
+ function: Function,
102
+ ) -> Function:
103
+ """Agent behaviour. A bare method on a Tag is already an Action; this
104
+ is the explicit, stackable spelling."""
105
+
106
+ return _mark(
107
+ function,
108
+ "action",
109
+ )
110
+
111
+
112
+ def Record(
113
+ function: Function,
114
+ ) -> Function:
115
+ """Agent state. The builder runs at tagging and its value is stored on
116
+ the Agent. A second positional parameter receives the value already
117
+ stored under that name, or None when there is none::
118
+
119
+ @Record
120
+ def spells(agent, stored):
121
+ return (stored or []) + ["Fireball"]
122
+ """
123
+
124
+ return _mark(
125
+ function,
126
+ "record",
127
+ )
128
+
129
+
130
+ def Underlay(
131
+ function: Function,
132
+ ) -> Function:
133
+ """Extend the prior visible contribution of the same name.
134
+
135
+ For an Action or a condition the second positional parameter receives a
136
+ callable that runs the prior contribution. For a Record the second
137
+ positional parameter receives the stored value (the mark is optional
138
+ there; the parameter alone is enough).
139
+ """
140
+
141
+ return _flag(
142
+ function,
143
+ _UNDERLAY,
144
+ )
145
+
146
+
147
+ def Rip(
148
+ function: Function,
149
+ ) -> Function:
150
+ """Teardown. Runs when the Agent leaves the Tag's Field. It is also a
151
+ normally callable Action."""
152
+
153
+ return _flag(
154
+ function,
155
+ _RIP,
156
+ )
157
+
158
+
159
+ class _Check_Mark:
160
+ """A mark for a named check: Imprint, Precondition, Postcondition.
161
+
162
+ Called, it marks the function. Read as a namespace, it names the
163
+ failure of one check: ``Precondition.Is_A_Caster`` is the error raised
164
+ when the Precondition declared as ``Is_A_Caster`` refuses, so a program
165
+ writes ``except Precondition.Is_A_Caster:`` in its own words.
166
+ """
167
+
168
+ def __init__(
169
+ mark,
170
+ kind: str,
171
+ failure: type | None,
172
+ doc: str,
173
+ name: str | None = None,
174
+ ) -> None:
175
+ mark.kind = kind
176
+ mark.failure = failure
177
+ mark.__name__ = name if name is not None else kind.capitalize()
178
+ mark.__doc__ = doc
179
+
180
+ def __call__(
181
+ mark,
182
+ function: Function,
183
+ ) -> Function:
184
+ return _mark(
185
+ function,
186
+ mark.kind,
187
+ )
188
+
189
+ def __getattr__(
190
+ mark,
191
+ name: str,
192
+ ) -> type:
193
+ if mark.failure is None:
194
+ raise AttributeError(
195
+ f"@{mark.__name__} names no failure of its own: it fails"
196
+ f" as a Precondition at the door, or as a Postcondition"
197
+ f" afterwards. Catch `Precondition.{name}` or"
198
+ f" `Postcondition.{name}`, whichever you mean to repair."
199
+ )
200
+
201
+ return getattr(
202
+ mark.failure,
203
+ name,
204
+ )
205
+
206
+ def __repr__(
207
+ mark,
208
+ ) -> str:
209
+ return f"<TopKit mark @{mark.__name__}>"
210
+
211
+
212
+ Imprint = _Check_Mark(
213
+ "imprint",
214
+ TagImprintError,
215
+ "Work performed after the Tag has applied.",
216
+ )
217
+
218
+
219
+ Precondition = _Check_Mark(
220
+ "precondition",
221
+ TagPreconditionError,
222
+ "A gate on the incoming Agent. Evaluated before the Tag applies.",
223
+ )
224
+
225
+ Postcondition = _Check_Mark(
226
+ "postcondition",
227
+ TagPostconditionError,
228
+ "A promise about the finished Agent. Evaluated after every Tagging.",
229
+ )
230
+
231
+
232
+ Pre = Precondition
233
+ Post = Postcondition
234
+
235
+
236
+ Requirement = _Check_Mark(
237
+ "condition",
238
+ None,
239
+ "A necessity in both directions: a gate on the incoming Agent and a"
240
+ " promise about it afterwards. The same as stacking @Pre and @Post"
241
+ " on one function, said in one word.",
242
+ "Requirement",
243
+ )
244
+
245
+
246
+ def Delete(
247
+ function: Function,
248
+ ) -> Function:
249
+ """Remove a visible contribution (or host member) by name."""
250
+
251
+ return _mark(
252
+ function,
253
+ "delete",
254
+ )
255
+
256
+
257
+ def Operation(
258
+ function: Function,
259
+ ) -> classmethod:
260
+ """Tag behaviour. The Tag is its first input."""
261
+
262
+ _mark(
263
+ function,
264
+ "operation",
265
+ )
266
+
267
+ return classmethod(function)
268
+
269
+
270
+ def Secret(
271
+ function: Function,
272
+ ) -> Function:
273
+ """Hide an Action or Record from code outside composition."""
274
+
275
+ return _flag(
276
+ function,
277
+ _SECRET,
278
+ )
279
+
280
+
281
+ def Public(
282
+ member: Any,
283
+ ) -> Any:
284
+ """Publish a Report or Operation on the Agent: a Report as a read-only
285
+ name, an Operation as an Action that forwards to it with the Agent as
286
+ its second input. Stacks with ``@Report`` / ``@Operation`` in either
287
+ order."""
288
+
289
+ if isinstance(member, Report):
290
+ member.public = True
291
+
292
+ return member
293
+
294
+ return _flag(
295
+ member,
296
+ _PUBLIC,
297
+ )
298
+
299
+
300
+ def Flag(
301
+ tag: type,
302
+ ) -> type:
303
+ """Mark a Tag as a keyword: searchable from the Agent's side by name
304
+ or by class, ``"Undead" in ghoul`` and ``Undead in ghoul``.
305
+
306
+ Applying a Flag to a host that defines its own ``in`` is refused.
307
+ """
308
+
309
+ if not isinstance(tag, type) or not hasattr(tag, "_topkit_field"):
310
+ raise TagDeclarationError(
311
+ "@Flag marks a Tag class"
312
+ )
313
+
314
+ setattr(
315
+ tag,
316
+ _FLAG,
317
+ True,
318
+ )
319
+
320
+ return tag
321
+
322
+
323
+ def _is_flag(
324
+ tag: type,
325
+ ) -> bool:
326
+ return bool(
327
+ tag.__dict__.get(
328
+ _FLAG,
329
+ False,
330
+ )
331
+ )
332
+
333
+
334
+ def Pin(
335
+ tag: type,
336
+ ) -> type:
337
+ """Mark a Tag whose Targets are Tags (STEP-SPEC-9).
338
+
339
+ ``Rare(Wizard)`` makes the Tag ``Wizard`` an Agent of ``Rare``: its
340
+ Records land on ``Wizard`` as Reports, its Actions as Operations, and
341
+ the Field of ``Rare`` is a population of Tags. A Pin applies to
342
+ nothing else, and its Bases must be Pins.
343
+ """
344
+
345
+ if not isinstance(tag, type) or not hasattr(tag, "_topkit_field"):
346
+ raise TagDeclarationError(
347
+ "@Pin marks a Tag class"
348
+ )
349
+
350
+ setattr(
351
+ tag,
352
+ _PIN,
353
+ True,
354
+ )
355
+
356
+ _check_pin_bases(tag)
357
+ _declarations_of(tag) # validate the members now, not at first pinning
358
+
359
+ return tag
360
+
361
+
362
+ def _is_pin(
363
+ tag: type,
364
+ ) -> bool:
365
+ """A Shape of a Pin is a Pin."""
366
+
367
+ return bool(
368
+ getattr(
369
+ tag,
370
+ _PIN,
371
+ False,
372
+ )
373
+ )
374
+
375
+
376
+ def _is_tag_base(
377
+ base: type,
378
+ ) -> bool:
379
+ """A Tag class other than the root ``Tag`` (the root is the only Tag
380
+ with no Tag among its own bases)."""
381
+
382
+ return hasattr(base, "_topkit_field") and any(
383
+ hasattr(deeper, "_topkit_field")
384
+ for deeper in base.__bases__
385
+ )
386
+
387
+
388
+ def _check_pin_bases(
389
+ tag: type,
390
+ ) -> None:
391
+ """One Form is all Pins or no Pins."""
392
+
393
+ bases = tuple(
394
+ base
395
+ for base in tag.__bases__
396
+ if _is_tag_base(base)
397
+ )
398
+ pins = [
399
+ base
400
+ for base in bases
401
+ if _is_pin(base)
402
+ ]
403
+ marked = bool(tag.__dict__.get(_PIN, False))
404
+
405
+ if not bases or len(pins) == len(bases):
406
+ return
407
+
408
+ if not pins and not marked:
409
+ return
410
+
411
+ raise TagDeclarationError(
412
+ f"{tag.__name__} mixes Pins and Tags in one Form; a Pin's"
413
+ " Bases must be Pins (STEP-SPEC-9 §2)"
414
+ )
415
+
416
+
417
+ class Report:
418
+ """Shared data belonging to a Tag, written like a Record::
419
+
420
+ @Report
421
+ def hit_die(tag):
422
+ return 8
423
+
424
+ The builder receives the Tag and runs once per Tag, on first read. A
425
+ second positional parameter receives the value the Tag's Bases give
426
+ that name, or None, so a Shape can extend a Base's Report the way a
427
+ Record extends what is stored.
428
+ """
429
+
430
+ def __init__(
431
+ report,
432
+ builder: Function,
433
+ ) -> None:
434
+ if not callable(builder):
435
+ raise TagDeclarationError(
436
+ "@Report marks a builder: `@Report def name(tag): ...`"
437
+ )
438
+
439
+ report.builder = builder
440
+ report.public = _has_flag(builder, _PUBLIC)
441
+ report.__name__ = builder.__name__
442
+ report.__doc__ = builder.__doc__
443
+ report._name = builder.__name__
444
+ report._values: "WeakKeyDictionary[type, Any]" = WeakKeyDictionary()
445
+
446
+ def __set_name__(
447
+ report,
448
+ owner: type,
449
+ name: str,
450
+ ) -> None:
451
+ report._name = name
452
+
453
+ def __get__(
454
+ report,
455
+ instance: object,
456
+ owner: type | None = None,
457
+ ) -> Any:
458
+ if owner is None:
459
+ owner = type(instance)
460
+
461
+ try:
462
+ return report._values[owner]
463
+ except KeyError:
464
+ pass
465
+
466
+ value = report._build(owner)
467
+ report._values[owner] = value
468
+
469
+ return value
470
+
471
+ def _build(
472
+ report,
473
+ owner: type,
474
+ ) -> Any:
475
+ if _parameters_of(report.builder).positional >= 2:
476
+ return report.builder(
477
+ owner,
478
+ report._inherited(owner),
479
+ )
480
+
481
+ return report.builder(owner)
482
+
483
+ def _inherited(
484
+ report,
485
+ owner: type,
486
+ ) -> Any:
487
+ """The value the Bases give this name, or None: the first member of
488
+ that name declared after this Report's own class in the MRO."""
489
+
490
+ passed_own_class = False
491
+
492
+ for klass in owner.__mro__:
493
+ member = klass.__dict__.get(report._name)
494
+
495
+ if member is report:
496
+ passed_own_class = True
497
+ continue
498
+
499
+ if not passed_own_class or member is None:
500
+ continue
501
+
502
+ if isinstance(member, Report):
503
+ return member.__get__(
504
+ None,
505
+ owner,
506
+ )
507
+
508
+ return member
509
+
510
+ return None
511
+
512
+ def __repr__(
513
+ report,
514
+ ) -> str:
515
+ return f"<Report {report._name}>"
516
+
517
+
518
+ # ------------------------------------------------------------------
519
+ # Scanning a Tag class
520
+ # ------------------------------------------------------------------
521
+
522
+
523
+ @dataclass(frozen=True)
524
+ class _Declarations:
525
+ actions: tuple[tuple[str, Function], ...]
526
+ records: tuple[tuple[str, Function], ...]
527
+ secrets: frozenset[str]
528
+ imprints: tuple[tuple[str, Function], ...]
529
+ preconditions: tuple[tuple[str, Function], ...]
530
+ postconditions: tuple[tuple[str, Function], ...]
531
+ deletions: tuple[str, ...]
532
+ reports: tuple[tuple[str, Any, bool], ...]
533
+ operations: tuple[tuple[str, Function, bool], ...]
534
+ rips: tuple[str, ...]
535
+ dunders: frozenset[str]
536
+ published: frozenset[str] # Agent-scope members marked @Public (Pins)
537
+
538
+
539
+ _scan_cache: "WeakKeyDictionary[type, _Declarations]" = WeakKeyDictionary()
540
+
541
+
542
+ def _declarations_of(
543
+ tag: type,
544
+ ) -> _Declarations:
545
+ cached = _scan_cache.get(tag)
546
+
547
+ if cached is None:
548
+ cached = _scan(tag)
549
+ _scan_cache[tag] = cached
550
+
551
+ return cached
552
+
553
+
554
+ def _is_private(
555
+ name: str,
556
+ ) -> bool:
557
+ return (
558
+ name.startswith("_")
559
+ and not _is_dunder(name)
560
+ )
561
+
562
+
563
+ def _is_dunder(
564
+ name: str,
565
+ ) -> bool:
566
+ return (
567
+ name.startswith("__")
568
+ and name.endswith("__")
569
+ )
570
+
571
+
572
+ def _kind_of(
573
+ attribute: Any,
574
+ ) -> str | None:
575
+ function = getattr(
576
+ attribute,
577
+ "__func__",
578
+ attribute,
579
+ )
580
+
581
+ return getattr(
582
+ function,
583
+ _KIND,
584
+ None,
585
+ )
586
+
587
+
588
+ def _has_flag(
589
+ attribute: Any,
590
+ name: str,
591
+ ) -> bool:
592
+ function = getattr(
593
+ attribute,
594
+ "__func__",
595
+ attribute,
596
+ )
597
+
598
+ return bool(
599
+ getattr(
600
+ function,
601
+ name,
602
+ False,
603
+ )
604
+ )
605
+
606
+
607
+ _NAMED_FAILURES: dict[str, type] = {
608
+ "imprint": TagImprintError,
609
+ "precondition": TagPreconditionError,
610
+ "postcondition": TagPostconditionError,
611
+ }
612
+
613
+
614
+ def _name_checks(
615
+ namespace: dict[str, Any],
616
+ ) -> None:
617
+ """Give every check in a Tag body its named failure, at class creation.
618
+
619
+ Done when the class is made, not at the first tagging, so
620
+ ``except Precondition.Is_A_Caster`` is valid as soon as the Tag exists.
621
+ """
622
+
623
+ for name, attribute in namespace.items():
624
+ if _is_private(name):
625
+ continue
626
+
627
+ kind = _kind_of(attribute)
628
+
629
+ if kind == "condition":
630
+ TagPreconditionError.Named(name)
631
+ TagPostconditionError.Named(name)
632
+ continue
633
+
634
+ failure = _NAMED_FAILURES.get(kind)
635
+
636
+ if failure is not None:
637
+ failure.Named(name)
638
+
639
+
640
+ def _scan(
641
+ tag: type,
642
+ ) -> _Declarations:
643
+ actions: list[tuple[str, Function]] = []
644
+ records: list[tuple[str, Function]] = []
645
+ secrets: set[str] = set()
646
+ imprints: list[tuple[str, Function]] = []
647
+ preconditions: list[tuple[str, Function]] = []
648
+ postconditions: list[tuple[str, Function]] = []
649
+ deletions: list[str] = []
650
+ reports: list[tuple[str, Any, bool]] = []
651
+ operations: list[tuple[str, Function, bool]] = []
652
+ rips: list[str] = []
653
+ dunders: set[str] = set()
654
+ published: set[str] = set()
655
+ managed = tag.__dict__.get(STATE) # names a Pin landed here
656
+
657
+ if managed is not None:
658
+ _emit_published_pins(
659
+ managed,
660
+ reports,
661
+ operations,
662
+ )
663
+
664
+ for name, attribute in tag.__dict__.items():
665
+ if _is_private(name):
666
+ continue
667
+
668
+ if managed is not None and (
669
+ name in managed.actions
670
+ or name in managed.records
671
+ ):
672
+ continue
673
+
674
+ if isinstance(attribute, Report):
675
+ if attribute.public and _has_flag(attribute.builder, _SECRET):
676
+ _reject_both(tag, name, True, True)
677
+
678
+ reports.append(
679
+ (
680
+ name,
681
+ attribute,
682
+ attribute.public,
683
+ )
684
+ )
685
+ continue
686
+
687
+ kind = _kind_of(attribute)
688
+ secret = _has_flag(attribute, _SECRET)
689
+ public = _has_flag(attribute, _PUBLIC)
690
+
691
+ if kind == "operation":
692
+ _reject_both(tag, name, secret, public)
693
+ operations.append(
694
+ (
695
+ name,
696
+ attribute.__func__,
697
+ public,
698
+ )
699
+ )
700
+ continue
701
+
702
+ if isinstance(
703
+ attribute,
704
+ (
705
+ classmethod,
706
+ staticmethod,
707
+ ),
708
+ ):
709
+ continue
710
+
711
+ if not callable(attribute):
712
+ continue
713
+
714
+ if public:
715
+ published.add(name)
716
+
717
+ if kind == "record":
718
+ _reject_both(tag, name, secret, public)
719
+ records.append(
720
+ (
721
+ name,
722
+ attribute,
723
+ )
724
+ )
725
+
726
+ if secret:
727
+ secrets.add(name)
728
+
729
+ continue
730
+
731
+ if kind == "imprint":
732
+ imprints.append(
733
+ (
734
+ name,
735
+ attribute,
736
+ )
737
+ )
738
+ continue
739
+
740
+ if kind in ("precondition", "condition"):
741
+ preconditions.append(
742
+ (
743
+ name,
744
+ attribute,
745
+ )
746
+ )
747
+
748
+ if kind in ("postcondition", "condition"):
749
+ postconditions.append(
750
+ (
751
+ name,
752
+ attribute,
753
+ )
754
+ )
755
+
756
+ if kind in _CONDITION_KINDS:
757
+ continue
758
+
759
+ if kind == "delete":
760
+ deletions.append(name)
761
+ continue
762
+
763
+ # Anything else callable is an Action (kind "action" or unmarked).
764
+ _reject_both(tag, name, secret, public)
765
+ actions.append(
766
+ (
767
+ name,
768
+ attribute,
769
+ )
770
+ )
771
+
772
+ if secret:
773
+ secrets.add(name)
774
+
775
+ if _has_flag(attribute, _RIP):
776
+ rips.append(name)
777
+
778
+ if _is_dunder(name):
779
+ dunders.add(name)
780
+
781
+ declarations = _Declarations(
782
+ actions=tuple(actions),
783
+ records=tuple(records),
784
+ secrets=frozenset(secrets),
785
+ imprints=tuple(imprints),
786
+ preconditions=tuple(preconditions),
787
+ postconditions=tuple(postconditions),
788
+ deletions=tuple(deletions),
789
+ reports=tuple(reports),
790
+ operations=tuple(operations),
791
+ rips=tuple(rips),
792
+ dunders=frozenset(dunders),
793
+ published=frozenset(published),
794
+ )
795
+
796
+ if _is_pin(tag):
797
+ _validate_pin(
798
+ tag,
799
+ declarations,
800
+ )
801
+
802
+ return declarations
803
+
804
+
805
+ def _emit_published_pins(
806
+ managed: Any,
807
+ reports: list[tuple[str, Any, bool]],
808
+ operations: list[tuple[str, Function, bool]],
809
+ ) -> None:
810
+ """Members a Pin landed on this Tag with @Public are the Tag's own
811
+ published Reports and Operations to every Agent tagged from now on
812
+ (STEP-SPEC-9 §5). Present Agents were reached at pinning."""
813
+
814
+ for name in managed.published:
815
+ if name in managed.records:
816
+ reports.append(
817
+ (
818
+ name,
819
+ None,
820
+ True,
821
+ )
822
+ )
823
+ elif name in managed.actions:
824
+ operations.append(
825
+ (
826
+ name,
827
+ managed.actions[name],
828
+ True,
829
+ )
830
+ )
831
+
832
+
833
+ def _validate_pin(
834
+ tag: type,
835
+ declarations: _Declarations,
836
+ ) -> None:
837
+ """A Pin's members carry no @Delete and no special-method Actions, and
838
+ its own Reports and Operations are not published: each would need a
839
+ descriptor or a hook on the Tag's metaclass, and none has a meaning
840
+ there yet. @Secret and @Public on its Agent-scope members do."""
841
+
842
+ problems: list[str] = []
843
+
844
+ published = [
845
+ name
846
+ for name, _value, public in declarations.reports
847
+ if public
848
+ ] + [
849
+ name
850
+ for name, _function, public in declarations.operations
851
+ if public
852
+ ]
853
+
854
+ if published:
855
+ problems.append(
856
+ "@Public on the Pin's own Reports / Operations " + ", ".join(published)
857
+ )
858
+
859
+ if declarations.deletions:
860
+ problems.append(
861
+ "@Delete of " + ", ".join(declarations.deletions)
862
+ )
863
+
864
+ if declarations.dunders:
865
+ problems.append(
866
+ "special-method Actions " + ", ".join(sorted(declarations.dunders))
867
+ )
868
+
869
+ if problems:
870
+ raise TagDeclarationError(
871
+ f"{tag.__name__} is a Pin; its members are plain:"
872
+ f" {'; '.join(problems)} (STEP-SPEC-9 §5)"
873
+ )
874
+
875
+
876
+ def _reject_both(
877
+ tag: type,
878
+ name: str,
879
+ secret: bool,
880
+ public: bool,
881
+ ) -> None:
882
+ """A modifier that restates the default is accepted; both at once is
883
+ a contradiction."""
884
+
885
+ if secret and public:
886
+ raise TagDeclarationError(
887
+ f"{tag.__name__}.{name}: @Secret and @Public together say"
888
+ " nothing; a member is internal or external"
889
+ )
890
+
891
+
892
+ # ------------------------------------------------------------------
893
+ # Parameters
894
+ # ------------------------------------------------------------------
895
+
896
+
897
+ @dataclass(frozen=True)
898
+ class _Parameters:
899
+ positional: int
900
+ named: tuple[tuple[str, bool], ...]
901
+ var_keyword: bool
902
+
903
+
904
+ _parameter_cache: "WeakKeyDictionary[Function, _Parameters]" = (
905
+ WeakKeyDictionary()
906
+ )
907
+
908
+
909
+ def _parameters_of(
910
+ function: Function,
911
+ ) -> _Parameters:
912
+ cached = _parameter_cache.get(function)
913
+
914
+ if cached is not None:
915
+ return cached
916
+
917
+ positional = 0
918
+ named: list[tuple[str, bool]] = []
919
+ var_keyword = False
920
+
921
+ for parameter in signature(function).parameters.values():
922
+ if parameter.kind is Parameter.VAR_KEYWORD:
923
+ var_keyword = True
924
+ continue
925
+
926
+ if parameter.kind is Parameter.VAR_POSITIONAL:
927
+ continue
928
+
929
+ if parameter.kind is not Parameter.KEYWORD_ONLY:
930
+ positional += 1
931
+
932
+ named.append(
933
+ (
934
+ parameter.name,
935
+ parameter.default is not Parameter.empty,
936
+ )
937
+ )
938
+
939
+ spec = _Parameters(
940
+ positional=positional,
941
+ named=tuple(named),
942
+ var_keyword=var_keyword,
943
+ )
944
+ _parameter_cache[function] = spec
945
+
946
+ return spec
947
+
948
+
949
+ def _takes_underlay(
950
+ function: Function,
951
+ ) -> bool:
952
+ """An Action or condition extends the prior contribution when marked
953
+ @Underlay. The mark requires a second positional parameter."""
954
+
955
+ if not _has_flag(function, _UNDERLAY):
956
+ return False
957
+
958
+ if _parameters_of(function).positional < 2:
959
+ raise TagDeclarationError(
960
+ f"{function.__qualname__} is marked @Underlay but has no"
961
+ " second positional parameter to receive the underlay"
962
+ )
963
+
964
+ return True
965
+
966
+
967
+ def _takes_stored(
968
+ function: Function,
969
+ ) -> bool:
970
+ """A Record builder receives the stored value when it declares a second
971
+ positional parameter (the @Underlay mark is accepted as documentation)."""
972
+
973
+ return _parameters_of(function).positional >= 2
974
+
975
+
976
+ def _protocol_inputs(
977
+ function: Function,
978
+ inputs: dict[str, Any],
979
+ skip: int,
980
+ ) -> dict[str, Any]:
981
+ """Bind application inputs to a protocol's named parameters.
982
+
983
+ The first ``skip`` positional parameters are bound by position (the
984
+ Agent, and an underlay or stored value when present), so their names
985
+ are the author's choice. Later parameters, positional or keyword-only,
986
+ are filled from ``inputs`` by name. A parameter the caller did not
987
+ supply keeps its own default, or receives None when it has none.
988
+ ``**kwargs`` receives any remaining inputs.
989
+ """
990
+
991
+ spec = _parameters_of(function)
992
+ bound: dict[str, Any] = {}
993
+
994
+ for index, (name, has_default) in enumerate(spec.named):
995
+ if index < skip:
996
+ continue
997
+
998
+ if name in inputs:
999
+ bound[name] = inputs[name]
1000
+ elif not has_default:
1001
+ bound[name] = None
1002
+
1003
+ if spec.var_keyword:
1004
+ for name, value in inputs.items():
1005
+ bound.setdefault(
1006
+ name,
1007
+ value,
1008
+ )
1009
+
1010
+ return bound