1# orm/collections.py
2# Copyright (C) 2005-2026 the SQLAlchemy authors and contributors
3# <see AUTHORS file>
4#
5# This module is part of SQLAlchemy and is released under
6# the MIT License: https://www.opensource.org/licenses/mit-license.php
7# mypy: allow-untyped-defs, allow-untyped-calls
8
9"""Support for collections of mapped entities.
10
11The collections package supplies the machinery used to inform the ORM of
12collection membership changes. An instrumentation via decoration approach is
13used, allowing arbitrary types (including built-ins) to be used as entity
14collections without requiring inheritance from a base class.
15
16Instrumentation decoration relays membership change events to the
17:class:`.CollectionAttributeImpl` that is currently managing the collection.
18The decorators observe function call arguments and return values, tracking
19entities entering or leaving the collection. Two decorator approaches are
20provided. One is a bundle of generic decorators that map function arguments
21and return values to events::
22
23 from sqlalchemy.orm.collections import collection
24
25
26 class MyClass:
27 # ...
28
29 @collection.adds(1)
30 def store(self, item):
31 self.data.append(item)
32
33 @collection.removes_return()
34 def pop(self):
35 return self.data.pop()
36
37The second approach is a bundle of targeted decorators that wrap appropriate
38append and remove notifiers around the mutation methods present in the
39standard Python ``list``, ``set`` and ``dict`` interfaces. These could be
40specified in terms of generic decorator recipes, but are instead hand-tooled
41for increased efficiency. The targeted decorators occasionally implement
42adapter-like behavior, such as mapping bulk-set methods (``extend``,
43``update``, ``__setslice__``, etc.) into the series of atomic mutation events
44that the ORM requires.
45
46The targeted decorators are used internally for automatic instrumentation of
47entity collection classes. Every collection class goes through a
48transformation process roughly like so:
49
501. If the class is a built-in, substitute a trivial sub-class
512. Is this class already instrumented?
523. Add in generic decorators
534. Sniff out the collection interface through duck-typing
545. Add targeted decoration to any undecorated interface method
55
56This process modifies the class at runtime, decorating methods and adding some
57bookkeeping properties. This isn't possible (or desirable) for built-in
58classes like ``list``, so trivial sub-classes are substituted to hold
59decoration::
60
61 class InstrumentedList(list):
62 pass
63
64Collection classes can be specified in ``relationship(collection_class=)`` as
65types or a function that returns an instance. Collection classes are
66inspected and instrumented during the mapper compilation phase. The
67collection_class callable will be executed once to produce a specimen
68instance, and the type of that specimen will be instrumented. Functions that
69return built-in types like ``lists`` will be adapted to produce instrumented
70instances.
71
72When extending a known type like ``list``, additional decorations are not
73generally not needed. Odds are, the extension method will delegate to a
74method that's already instrumented. For example::
75
76 class QueueIsh(list):
77 def push(self, item):
78 self.append(item)
79
80 def shift(self):
81 return self.pop(0)
82
83There's no need to decorate these methods. ``append`` and ``pop`` are already
84instrumented as part of the ``list`` interface. Decorating them would fire
85duplicate events, which should be avoided.
86
87The targeted decoration tries not to rely on other methods in the underlying
88collection class, but some are unavoidable. Many depend on 'read' methods
89being present to properly instrument a 'write', for example, ``__setitem__``
90needs ``__getitem__``. "Bulk" methods like ``update`` and ``extend`` may also
91reimplemented in terms of atomic appends and removes, so the ``extend``
92decoration will actually perform many ``append`` operations and not call the
93underlying method at all.
94
95Tight control over bulk operation and the firing of events is also possible by
96implementing the instrumentation internally in your methods. The basic
97instrumentation package works under the general assumption that collection
98mutation will not raise unusual exceptions. If you want to closely
99orchestrate append and remove events with exception management, internal
100instrumentation may be the answer. Within your method,
101``collection_adapter(self)`` will retrieve an object that you can use for
102explicit control over triggering append and remove events.
103
104The owning object and :class:`.CollectionAttributeImpl` are also reachable
105through the adapter, allowing for some very sophisticated behavior.
106
107"""
108
109from __future__ import annotations
110
111import operator
112import threading
113import typing
114from typing import Any
115from typing import Callable
116from typing import cast
117from typing import Collection
118from typing import Dict
119from typing import Iterable
120from typing import List
121from typing import NoReturn
122from typing import Optional
123from typing import Protocol
124from typing import Set
125from typing import Tuple
126from typing import Type
127from typing import TYPE_CHECKING
128from typing import TypeVar
129from typing import Union
130import weakref
131
132from .base import NO_KEY
133from .. import exc as sa_exc
134from .. import util
135from ..sql.base import NO_ARG
136from ..util.compat import inspect_getfullargspec
137
138if typing.TYPE_CHECKING:
139 from .attributes import _CollectionAttributeImpl
140 from .attributes import AttributeEventToken
141 from .mapped_collection import attribute_keyed_dict
142 from .mapped_collection import column_keyed_dict
143 from .mapped_collection import keyfunc_mapping
144 from .mapped_collection import KeyFuncDict # noqa: F401
145 from .state import InstanceState
146
147
148__all__ = [
149 "collection",
150 "collection_adapter",
151 "keyfunc_mapping",
152 "column_keyed_dict",
153 "attribute_keyed_dict",
154 "KeyFuncDict",
155 # old names in < 2.0
156 "mapped_collection",
157 "column_mapped_collection",
158 "attribute_mapped_collection",
159 "MappedCollection",
160]
161
162__instrumentation_mutex = threading.Lock()
163
164
165_CollectionFactoryType = Callable[[], "_AdaptedCollectionProtocol"]
166
167_T = TypeVar("_T", bound=Any)
168_KT = TypeVar("_KT", bound=Any)
169_VT = TypeVar("_VT", bound=Any)
170_COL = TypeVar("_COL", bound="Collection[Any]")
171_FN = TypeVar("_FN", bound="Callable[..., Any]")
172
173
174class _CollectionConverterProtocol(Protocol):
175 def __call__(self, collection: _COL) -> _COL: ...
176
177
178class _AdaptedCollectionProtocol(Protocol):
179 _sa_adapter: CollectionAdapter
180 _sa_appender: Callable[..., Any]
181 _sa_remover: Callable[..., Any]
182 _sa_iterator: Callable[..., Iterable[Any]]
183
184
185class collection:
186 """Decorators for entity collection classes.
187
188 The decorators fall into two groups: annotations and interception recipes.
189
190 The annotating decorators (appender, remover, iterator,
191 internally_instrumented) indicate the method's purpose and take no
192 arguments. They are not written with parens::
193
194 @collection.appender
195 def append(self, append): ...
196
197 The recipe decorators all require parens, even those that take no
198 arguments::
199
200 @collection.adds("entity")
201 def insert(self, position, entity): ...
202
203
204 @collection.removes_return()
205 def popitem(self): ...
206
207 """
208
209 # Bundled as a class solely for ease of use: packaging, doc strings,
210 # importability.
211
212 @staticmethod
213 def appender(fn):
214 """Tag the method as the collection appender.
215
216 The appender method is called with one positional argument: the value
217 to append. The method will be automatically decorated with 'adds(1)'
218 if not already decorated::
219
220 @collection.appender
221 def add(self, append): ...
222
223
224 # or, equivalently
225 @collection.appender
226 @collection.adds(1)
227 def add(self, append): ...
228
229
230 # for mapping type, an 'append' may kick out a previous value
231 # that occupies that slot. consider d['a'] = 'foo'- any previous
232 # value in d['a'] is discarded.
233 @collection.appender
234 @collection.replaces(1)
235 def add(self, entity):
236 key = some_key_func(entity)
237 previous = None
238 if key in self:
239 previous = self[key]
240 self[key] = entity
241 return previous
242
243 If the value to append is not allowed in the collection, you may
244 raise an exception. Something to remember is that the appender
245 will be called for each object mapped by a database query. If the
246 database contains rows that violate your collection semantics, you
247 will need to get creative to fix the problem, as access via the
248 collection will not work.
249
250 If the appender method is internally instrumented, you must also
251 receive the keyword argument '_sa_initiator' and ensure its
252 promulgation to collection events.
253
254 """
255 fn._sa_instrument_role = "appender"
256 return fn
257
258 @staticmethod
259 def remover(fn):
260 """Tag the method as the collection remover.
261
262 The remover method is called with one positional argument: the value
263 to remove. The method will be automatically decorated with
264 :meth:`removes_return` if not already decorated::
265
266 @collection.remover
267 def zap(self, entity): ...
268
269
270 # or, equivalently
271 @collection.remover
272 @collection.removes_return()
273 def zap(self): ...
274
275 If the value to remove is not present in the collection, you may
276 raise an exception or return None to ignore the error.
277
278 If the remove method is internally instrumented, you must also
279 receive the keyword argument '_sa_initiator' and ensure its
280 promulgation to collection events.
281
282 """
283 fn._sa_instrument_role = "remover"
284 return fn
285
286 @staticmethod
287 def iterator(fn):
288 """Tag the method as the collection remover.
289
290 The iterator method is called with no arguments. It is expected to
291 return an iterator over all collection members::
292
293 @collection.iterator
294 def __iter__(self): ...
295
296 """
297 fn._sa_instrument_role = "iterator"
298 return fn
299
300 @staticmethod
301 def internally_instrumented(fn):
302 """Tag the method as instrumented.
303
304 This tag will prevent any decoration from being applied to the
305 method. Use this if you are orchestrating your own calls to
306 :func:`.collection_adapter` in one of the basic SQLAlchemy
307 interface methods, or to prevent an automatic ABC method
308 decoration from wrapping your implementation::
309
310 # normally an 'extend' method on a list-like class would be
311 # automatically intercepted and re-implemented in terms of
312 # SQLAlchemy events and append(). your implementation will
313 # never be called, unless:
314 @collection.internally_instrumented
315 def extend(self, items): ...
316
317 """
318 fn._sa_instrumented = True
319 return fn
320
321 @staticmethod
322 def adds(arg: int) -> Callable[[_FN], _FN]:
323 """Mark the method as adding an entity to the collection.
324
325 Adds "add to collection" handling to the method. The decorator
326 argument indicates which method argument holds the SQLAlchemy-relevant
327 value. Arguments can be specified positionally (i.e. integer) or by
328 name::
329
330 @collection.adds(1)
331 def push(self, item): ...
332
333
334 @collection.adds("entity")
335 def do_stuff(self, thing, entity=None): ...
336
337 """
338
339 def decorator(fn):
340 fn._sa_instrument_before = ("fire_append_event", arg)
341 return fn
342
343 return decorator
344
345 @staticmethod
346 def replaces(arg):
347 """Mark the method as replacing an entity in the collection.
348
349 Adds "add to collection" and "remove from collection" handling to
350 the method. The decorator argument indicates which method argument
351 holds the SQLAlchemy-relevant value to be added, and return value, if
352 any will be considered the value to remove.
353
354 Arguments can be specified positionally (i.e. integer) or by name::
355
356 @collection.replaces(2)
357 def __setitem__(self, index, item): ...
358
359 """
360
361 def decorator(fn):
362 fn._sa_instrument_before = ("fire_append_event", arg)
363 fn._sa_instrument_after = "fire_remove_event"
364 return fn
365
366 return decorator
367
368 @staticmethod
369 def removes(arg):
370 """Mark the method as removing an entity in the collection.
371
372 Adds "remove from collection" handling to the method. The decorator
373 argument indicates which method argument holds the SQLAlchemy-relevant
374 value to be removed. Arguments can be specified positionally (i.e.
375 integer) or by name::
376
377 @collection.removes(1)
378 def zap(self, item): ...
379
380 For methods where the value to remove is not known at call-time, use
381 collection.removes_return.
382
383 """
384
385 def decorator(fn):
386 fn._sa_instrument_before = ("fire_remove_event", arg)
387 return fn
388
389 return decorator
390
391 @staticmethod
392 def removes_return():
393 """Mark the method as removing an entity in the collection.
394
395 Adds "remove from collection" handling to the method. The return
396 value of the method, if any, is considered the value to remove. The
397 method arguments are not inspected::
398
399 @collection.removes_return()
400 def pop(self): ...
401
402 For methods where the value to remove is known at call-time, use
403 collection.remove.
404
405 """
406
407 def decorator(fn):
408 fn._sa_instrument_after = "fire_remove_event"
409 return fn
410
411 return decorator
412
413
414if TYPE_CHECKING:
415
416 def collection_adapter(collection: Collection[Any]) -> CollectionAdapter:
417 """Fetch the :class:`.CollectionAdapter` for a collection."""
418
419else:
420 collection_adapter = operator.attrgetter("_sa_adapter")
421
422
423class CollectionAdapter:
424 """Bridges between the ORM and arbitrary Python collections.
425
426 Proxies base-level collection operations (append, remove, iterate)
427 to the underlying Python collection, and emits add/remove events for
428 entities entering or leaving the collection.
429
430 The ORM uses :class:`.CollectionAdapter` exclusively for interaction with
431 entity collections.
432
433
434 """
435
436 __slots__ = (
437 "attr",
438 "_key",
439 "_data",
440 "owner_state",
441 "invalidated",
442 "empty",
443 )
444
445 attr: _CollectionAttributeImpl
446 _key: str
447
448 # this is actually a weakref; see note in constructor
449 _data: Callable[..., _AdaptedCollectionProtocol]
450
451 owner_state: InstanceState[Any]
452 invalidated: bool
453 empty: bool
454
455 def __init__(
456 self,
457 attr: _CollectionAttributeImpl,
458 owner_state: InstanceState[Any],
459 data: _AdaptedCollectionProtocol,
460 ):
461 self.attr = attr
462 self._key = attr.key
463
464 # this weakref stays referenced throughout the lifespan of
465 # CollectionAdapter. so while the weakref can return None, this
466 # is realistically only during garbage collection of this object, so
467 # we type this as a callable that returns _AdaptedCollectionProtocol
468 # in all cases.
469 self._data = weakref.ref(data) # type: ignore
470
471 self.owner_state = owner_state
472 data._sa_adapter = self
473 self.invalidated = False
474 self.empty = False
475
476 def _warn_invalidated(self) -> None:
477 util.warn("This collection has been invalidated.")
478
479 @property
480 def data(self) -> _AdaptedCollectionProtocol:
481 "The entity collection being adapted."
482 return self._data()
483
484 @property
485 def _referenced_by_owner(self) -> bool:
486 """return True if the owner state still refers to this collection.
487
488 This will return False within a bulk replace operation,
489 where this collection is the one being replaced.
490
491 """
492 return self.owner_state.dict[self._key] is self._data()
493
494 def bulk_appender(self):
495 return self._data()._sa_appender
496
497 def append_with_event(
498 self, item: Any, initiator: Optional[AttributeEventToken] = None
499 ) -> None:
500 """Add an entity to the collection, firing mutation events."""
501
502 self._data()._sa_appender(item, _sa_initiator=initiator)
503
504 def _set_empty(self, user_data):
505 assert (
506 not self.empty
507 ), "This collection adapter is already in the 'empty' state"
508 self.empty = True
509 self.owner_state._empty_collections[self._key] = user_data
510
511 def _reset_empty(self) -> None:
512 assert (
513 self.empty
514 ), "This collection adapter is not in the 'empty' state"
515 self.empty = False
516 self.owner_state.dict[self._key] = (
517 self.owner_state._empty_collections.pop(self._key)
518 )
519
520 def _refuse_empty(self) -> NoReturn:
521 raise sa_exc.InvalidRequestError(
522 "This is a special 'empty' collection which cannot accommodate "
523 "internal mutation operations"
524 )
525
526 def append_without_event(self, item: Any) -> None:
527 """Add or restore an entity to the collection, firing no events."""
528
529 if self.empty:
530 self._refuse_empty()
531 self._data()._sa_appender(item, _sa_initiator=False)
532
533 def append_multiple_without_event(self, items: Iterable[Any]) -> None:
534 """Add or restore an entity to the collection, firing no events."""
535 if self.empty:
536 self._refuse_empty()
537 appender = self._data()._sa_appender
538 for item in items:
539 appender(item, _sa_initiator=False)
540
541 def bulk_remover(self):
542 return self._data()._sa_remover
543
544 def remove_with_event(
545 self, item: Any, initiator: Optional[AttributeEventToken] = None
546 ) -> None:
547 """Remove an entity from the collection, firing mutation events."""
548 self._data()._sa_remover(item, _sa_initiator=initiator)
549
550 def remove_without_event(self, item: Any) -> None:
551 """Remove an entity from the collection, firing no events."""
552 if self.empty:
553 self._refuse_empty()
554 self._data()._sa_remover(item, _sa_initiator=False)
555
556 def clear_with_event(
557 self, initiator: Optional[AttributeEventToken] = None
558 ) -> None:
559 """Empty the collection, firing a mutation event for each entity."""
560
561 if self.empty:
562 self._refuse_empty()
563 remover = self._data()._sa_remover
564 for item in list(self):
565 remover(item, _sa_initiator=initiator)
566
567 def clear_without_event(self) -> None:
568 """Empty the collection, firing no events."""
569
570 if self.empty:
571 self._refuse_empty()
572 remover = self._data()._sa_remover
573 for item in list(self):
574 remover(item, _sa_initiator=False)
575
576 def __iter__(self):
577 """Iterate over entities in the collection."""
578
579 return iter(self._data()._sa_iterator())
580
581 def __len__(self):
582 """Count entities in the collection."""
583 return len(list(self._data()._sa_iterator()))
584
585 def __bool__(self):
586 return True
587
588 def _fire_append_wo_mutation_event_bulk(
589 self, items, initiator=None, key=NO_KEY
590 ):
591 if not items:
592 return
593
594 if initiator is not False:
595 if self.invalidated:
596 self._warn_invalidated()
597
598 if self.empty:
599 self._reset_empty()
600
601 for item in items:
602 self.attr.fire_append_wo_mutation_event(
603 self.owner_state,
604 self.owner_state.dict,
605 item,
606 initiator,
607 key,
608 )
609
610 def fire_append_wo_mutation_event(self, item, initiator=None, key=NO_KEY):
611 """Notify that a entity is entering the collection but is already
612 present.
613
614
615 Initiator is a token owned by the InstrumentedAttribute that
616 initiated the membership mutation, and should be left as None
617 unless you are passing along an initiator value from a chained
618 operation.
619
620 .. versionadded:: 1.4.15
621
622 """
623 if initiator is not False:
624 if self.invalidated:
625 self._warn_invalidated()
626
627 if self.empty:
628 self._reset_empty()
629
630 return self.attr.fire_append_wo_mutation_event(
631 self.owner_state, self.owner_state.dict, item, initiator, key
632 )
633 else:
634 return item
635
636 def fire_append_event(self, item, initiator=None, key=NO_KEY):
637 """Notify that a entity has entered the collection.
638
639 Initiator is a token owned by the InstrumentedAttribute that
640 initiated the membership mutation, and should be left as None
641 unless you are passing along an initiator value from a chained
642 operation.
643
644 """
645 if initiator is not False:
646 if self.invalidated:
647 self._warn_invalidated()
648
649 if self.empty:
650 self._reset_empty()
651
652 return self.attr.fire_append_event(
653 self.owner_state, self.owner_state.dict, item, initiator, key
654 )
655 else:
656 return item
657
658 def _fire_remove_event_bulk(self, items, initiator=None, key=NO_KEY):
659 if not items:
660 return
661
662 if initiator is not False:
663 if self.invalidated:
664 self._warn_invalidated()
665
666 if self.empty:
667 self._reset_empty()
668
669 for item in items:
670 self.attr.fire_remove_event(
671 self.owner_state,
672 self.owner_state.dict,
673 item,
674 initiator,
675 key,
676 )
677
678 def fire_remove_event(self, item, initiator=None, key=NO_KEY):
679 """Notify that a entity has been removed from the collection.
680
681 Initiator is the InstrumentedAttribute that initiated the membership
682 mutation, and should be left as None unless you are passing along
683 an initiator value from a chained operation.
684
685 """
686 if initiator is not False:
687 if self.invalidated:
688 self._warn_invalidated()
689
690 if self.empty:
691 self._reset_empty()
692
693 self.attr.fire_remove_event(
694 self.owner_state, self.owner_state.dict, item, initiator, key
695 )
696
697 def fire_pre_remove_event(self, initiator=None, key=NO_KEY):
698 """Notify that an entity is about to be removed from the collection.
699
700 Only called if the entity cannot be removed after calling
701 fire_remove_event().
702
703 """
704 if self.invalidated:
705 self._warn_invalidated()
706 self.attr.fire_pre_remove_event(
707 self.owner_state,
708 self.owner_state.dict,
709 initiator=initiator,
710 key=key,
711 )
712
713 def __getstate__(self):
714 return {
715 "key": self._key,
716 "owner_state": self.owner_state,
717 "owner_cls": self.owner_state.class_,
718 "data": self.data,
719 "invalidated": self.invalidated,
720 "empty": self.empty,
721 }
722
723 def __setstate__(self, d):
724 self._key = d["key"]
725 self.owner_state = d["owner_state"]
726
727 # see note in constructor regarding this type: ignore
728 self._data = weakref.ref(d["data"]) # type: ignore
729
730 d["data"]._sa_adapter = self
731 self.invalidated = d["invalidated"]
732 self.attr = getattr(d["owner_cls"], self._key).impl
733 self.empty = d.get("empty", False)
734
735
736def bulk_replace(values, existing_adapter, new_adapter, initiator=None):
737 """Load a new collection, firing events based on prior like membership.
738
739 Appends instances in ``values`` onto the ``new_adapter``. Events will be
740 fired for any instance not present in the ``existing_adapter``. Any
741 instances in ``existing_adapter`` not present in ``values`` will have
742 remove events fired upon them.
743
744 :param values: An iterable of collection member instances
745
746 :param existing_adapter: A :class:`.CollectionAdapter` of
747 instances to be replaced
748
749 :param new_adapter: An empty :class:`.CollectionAdapter`
750 to load with ``values``
751
752
753 """
754
755 assert isinstance(values, list)
756
757 idset = util.IdentitySet
758 existing_idset = idset(existing_adapter or ())
759 constants = existing_idset.intersection(values or ())
760 additions = idset(values or ()).difference(constants)
761 removals = existing_idset.difference(constants)
762
763 appender = new_adapter.bulk_appender()
764
765 for member in values or ():
766 if member in additions:
767 appender(member, _sa_initiator=initiator)
768 elif member in constants:
769 appender(member, _sa_initiator=False)
770
771 if existing_adapter:
772 existing_adapter._fire_append_wo_mutation_event_bulk(
773 constants, initiator=initiator
774 )
775 existing_adapter._fire_remove_event_bulk(removals, initiator=initiator)
776
777
778def _prepare_instrumentation(
779 factory: Union[Type[Collection[Any]], _CollectionFactoryType],
780) -> _CollectionFactoryType:
781 """Prepare a callable for future use as a collection class factory.
782
783 Given a collection class factory (either a type or no-arg callable),
784 return another factory that will produce compatible instances when
785 called.
786
787 This function is responsible for converting collection_class=list
788 into the run-time behavior of collection_class=InstrumentedList.
789
790 """
791
792 impl_factory: _CollectionFactoryType
793
794 # Convert a builtin to 'Instrumented*'
795 if factory in __canned_instrumentation:
796 impl_factory = __canned_instrumentation[factory]
797 else:
798 impl_factory = cast(_CollectionFactoryType, factory)
799
800 cls: Union[_CollectionFactoryType, Type[Collection[Any]]]
801
802 # Create a specimen
803 cls = type(impl_factory())
804
805 # Did factory callable return a builtin?
806 if cls in __canned_instrumentation:
807 # if so, just convert.
808 # in previous major releases, this codepath wasn't working and was
809 # not covered by tests. prior to that it supplied a "wrapper"
810 # function that would return the class, though the rationale for this
811 # case is not known
812 impl_factory = __canned_instrumentation[cls]
813 cls = type(impl_factory())
814
815 # Instrument the class if needed.
816 if __instrumentation_mutex.acquire():
817 try:
818 if getattr(cls, "_sa_instrumented", None) != id(cls):
819 _instrument_class(cls)
820 finally:
821 __instrumentation_mutex.release()
822
823 return impl_factory
824
825
826def _instrument_class(cls):
827 """Modify methods in a class and install instrumentation."""
828
829 # In the normal call flow, a request for any of the 3 basic collection
830 # types is transformed into one of our trivial subclasses
831 # (e.g. InstrumentedList). Catch anything else that sneaks in here...
832 if cls.__module__ == "__builtin__":
833 raise sa_exc.ArgumentError(
834 "Can not instrument a built-in type. Use a "
835 "subclass, even a trivial one."
836 )
837
838 roles, methods = _locate_roles_and_methods(cls)
839
840 _setup_canned_roles(cls, roles, methods)
841
842 _assert_required_roles(cls, roles, methods)
843
844 _set_collection_attributes(cls, roles, methods)
845
846
847def _locate_roles_and_methods(cls):
848 """search for _sa_instrument_role-decorated methods in
849 method resolution order, assign to roles.
850
851 """
852
853 roles: Dict[str, str] = {}
854 methods: Dict[str, Tuple[Optional[str], Optional[int], Optional[str]]] = {}
855
856 for supercls in cls.__mro__:
857 for name, method in vars(supercls).items():
858 if not callable(method):
859 continue
860
861 # note role declarations
862 if hasattr(method, "_sa_instrument_role"):
863 role = method._sa_instrument_role
864 assert role in ("appender", "remover", "iterator")
865 roles.setdefault(role, name)
866
867 # transfer instrumentation requests from decorated function
868 # to the combined queue
869 before: Optional[Tuple[str, int]] = None
870 after: Optional[str] = None
871
872 if hasattr(method, "_sa_instrument_before"):
873 op, argument = method._sa_instrument_before
874 assert op in ("fire_append_event", "fire_remove_event")
875 before = op, argument
876 if hasattr(method, "_sa_instrument_after"):
877 op = method._sa_instrument_after
878 assert op in ("fire_append_event", "fire_remove_event")
879 after = op
880 if before:
881 methods[name] = before + (after,)
882 elif after:
883 methods[name] = None, None, after
884 return roles, methods
885
886
887def _setup_canned_roles(cls, roles, methods):
888 """see if this class has "canned" roles based on a known
889 collection type (dict, set, list). Apply those roles
890 as needed to the "roles" dictionary, and also
891 prepare "decorator" methods
892
893 """
894 collection_type = util.duck_type_collection(cls)
895 if collection_type in __interfaces:
896 assert collection_type is not None
897 canned_roles, decorators = __interfaces[collection_type]
898 for role, name in canned_roles.items():
899 roles.setdefault(role, name)
900
901 # apply ABC auto-decoration to methods that need it
902 for method, decorator in decorators.items():
903 fn = getattr(cls, method, None)
904 if (
905 fn
906 and method not in methods
907 and not hasattr(fn, "_sa_instrumented")
908 ):
909 setattr(cls, method, decorator(fn))
910
911
912def _assert_required_roles(cls, roles, methods):
913 """ensure all roles are present, and apply implicit instrumentation if
914 needed
915
916 """
917 if "appender" not in roles or not hasattr(cls, roles["appender"]):
918 raise sa_exc.ArgumentError(
919 "Type %s must elect an appender method to be "
920 "a collection class" % cls.__name__
921 )
922 elif roles["appender"] not in methods and not hasattr(
923 getattr(cls, roles["appender"]), "_sa_instrumented"
924 ):
925 methods[roles["appender"]] = ("fire_append_event", 1, None)
926
927 if "remover" not in roles or not hasattr(cls, roles["remover"]):
928 raise sa_exc.ArgumentError(
929 "Type %s must elect a remover method to be "
930 "a collection class" % cls.__name__
931 )
932 elif roles["remover"] not in methods and not hasattr(
933 getattr(cls, roles["remover"]), "_sa_instrumented"
934 ):
935 methods[roles["remover"]] = ("fire_remove_event", 1, None)
936
937 if "iterator" not in roles or not hasattr(cls, roles["iterator"]):
938 raise sa_exc.ArgumentError(
939 "Type %s must elect an iterator method to be "
940 "a collection class" % cls.__name__
941 )
942
943
944def _set_collection_attributes(cls, roles, methods):
945 """apply ad-hoc instrumentation from decorators, class-level defaults
946 and implicit role declarations
947
948 """
949 for method_name, (before, argument, after) in methods.items():
950 setattr(
951 cls,
952 method_name,
953 _instrument_membership_mutator(
954 getattr(cls, method_name), before, argument, after
955 ),
956 )
957 # intern the role map
958 for role, method_name in roles.items():
959 setattr(cls, "_sa_%s" % role, getattr(cls, method_name))
960
961 cls._sa_adapter = None
962
963 cls._sa_instrumented = id(cls)
964
965
966def _instrument_membership_mutator(method, before, argument, after):
967 """Route method args and/or return value through the collection
968 adapter."""
969 # This isn't smart enough to handle @adds(1) for 'def fn(self, (a, b))'
970 if before:
971 fn_args = list(
972 util.flatten_iterator(inspect_getfullargspec(method)[0])
973 )
974 if isinstance(argument, int):
975 pos_arg = argument
976 named_arg = len(fn_args) > argument and fn_args[argument] or None
977 else:
978 if argument in fn_args:
979 pos_arg = fn_args.index(argument)
980 else:
981 pos_arg = None
982 named_arg = argument
983 del fn_args
984
985 def wrapper(*args, **kw):
986 if before:
987 if pos_arg is None:
988 if named_arg not in kw:
989 raise sa_exc.ArgumentError(
990 "Missing argument %s" % argument
991 )
992 value = kw[named_arg]
993 else:
994 if len(args) > pos_arg:
995 value = args[pos_arg]
996 elif named_arg in kw:
997 value = kw[named_arg]
998 else:
999 raise sa_exc.ArgumentError(
1000 "Missing argument %s" % argument
1001 )
1002
1003 initiator = kw.pop("_sa_initiator", None)
1004 if initiator is False:
1005 executor = None
1006 else:
1007 executor = args[0]._sa_adapter
1008
1009 if before and executor:
1010 getattr(executor, before)(value, initiator)
1011
1012 if not after or not executor:
1013 return method(*args, **kw)
1014 else:
1015 res = method(*args, **kw)
1016 if res is not None:
1017 getattr(executor, after)(res, initiator)
1018 return res
1019
1020 wrapper._sa_instrumented = True # type: ignore[attr-defined]
1021 if hasattr(method, "_sa_instrument_role"):
1022 wrapper._sa_instrument_role = method._sa_instrument_role # type: ignore[attr-defined] # noqa: E501
1023 wrapper.__name__ = method.__name__
1024 wrapper.__doc__ = method.__doc__
1025 return wrapper
1026
1027
1028def __set_wo_mutation(collection, item, _sa_initiator=None):
1029 """Run set wo mutation events.
1030
1031 The collection is not mutated.
1032
1033 """
1034 if _sa_initiator is not False:
1035 executor = collection._sa_adapter
1036 if executor:
1037 executor.fire_append_wo_mutation_event(
1038 item, _sa_initiator, key=None
1039 )
1040
1041
1042def __set(collection, item, _sa_initiator, key):
1043 """Run set events.
1044
1045 This event always occurs before the collection is actually mutated.
1046
1047 """
1048
1049 if _sa_initiator is not False:
1050 executor = collection._sa_adapter
1051 if executor:
1052 item = executor.fire_append_event(item, _sa_initiator, key=key)
1053 return item
1054
1055
1056def __del(collection, item, _sa_initiator, key):
1057 """Run del events.
1058
1059 This event occurs before the collection is actually mutated, *except*
1060 in the case of a pop operation, in which case it occurs afterwards.
1061 For pop operations, the __before_pop hook is called before the
1062 operation occurs.
1063
1064 """
1065 if _sa_initiator is not False:
1066 executor = collection._sa_adapter
1067 if executor:
1068 executor.fire_remove_event(item, _sa_initiator, key=key)
1069
1070
1071def __before_pop(collection, _sa_initiator=None):
1072 """An event which occurs on a before a pop() operation occurs."""
1073 executor = collection._sa_adapter
1074 if executor:
1075 executor.fire_pre_remove_event(_sa_initiator)
1076
1077
1078def _list_decorators() -> Dict[str, Callable[[_FN], _FN]]:
1079 """Tailored instrumentation wrappers for any list-like class."""
1080
1081 def _tidy(fn):
1082 fn._sa_instrumented = True
1083 fn.__doc__ = getattr(list, fn.__name__).__doc__
1084
1085 def append(fn):
1086 def append(self, item, _sa_initiator=None):
1087 item = __set(self, item, _sa_initiator, NO_KEY)
1088 fn(self, item)
1089
1090 _tidy(append)
1091 return append
1092
1093 def remove(fn):
1094 def remove(self, value, _sa_initiator=None):
1095 __del(self, value, _sa_initiator, NO_KEY)
1096 # testlib.pragma exempt:__eq__
1097 fn(self, value)
1098
1099 _tidy(remove)
1100 return remove
1101
1102 def insert(fn):
1103 def insert(self, index, value):
1104 value = __set(self, value, None, index)
1105 fn(self, index, value)
1106
1107 _tidy(insert)
1108 return insert
1109
1110 def __setitem__(fn):
1111 def __setitem__(self, index, value):
1112 if not isinstance(index, slice):
1113 existing = self[index]
1114 if existing is not None:
1115 __del(self, existing, None, index)
1116 value = __set(self, value, None, index)
1117 fn(self, index, value)
1118 else:
1119 # slice assignment requires __delitem__, insert, __len__
1120 step = index.step or 1
1121 start = index.start or 0
1122 if start < 0:
1123 start += len(self)
1124 if index.stop is not None:
1125 stop = index.stop
1126 else:
1127 stop = len(self)
1128 if stop < 0:
1129 stop += len(self)
1130
1131 if step == 1:
1132 if value is self:
1133 return
1134 for i in range(start, stop, step):
1135 if len(self) > start:
1136 del self[start]
1137
1138 for i, item in enumerate(value):
1139 self.insert(i + start, item)
1140 else:
1141 rng = list(range(start, stop, step))
1142 if len(value) != len(rng):
1143 raise ValueError(
1144 "attempt to assign sequence of size %s to "
1145 "extended slice of size %s"
1146 % (len(value), len(rng))
1147 )
1148 for i, item in zip(rng, value):
1149 self.__setitem__(i, item)
1150
1151 _tidy(__setitem__)
1152 return __setitem__
1153
1154 def __delitem__(fn):
1155 def __delitem__(self, index):
1156 if not isinstance(index, slice):
1157 item = self[index]
1158 __del(self, item, None, index)
1159 fn(self, index)
1160 else:
1161 # slice deletion requires __getslice__ and a slice-groking
1162 # __getitem__ for stepped deletion
1163 # note: not breaking this into atomic dels
1164 for item in self[index]:
1165 __del(self, item, None, index)
1166 fn(self, index)
1167
1168 _tidy(__delitem__)
1169 return __delitem__
1170
1171 def extend(fn):
1172 def extend(self, iterable):
1173 for value in list(iterable):
1174 self.append(value)
1175
1176 _tidy(extend)
1177 return extend
1178
1179 def __iadd__(fn):
1180 def __iadd__(self, iterable):
1181 # list.__iadd__ takes any iterable and seems to let TypeError
1182 # raise as-is instead of returning NotImplemented
1183 for value in list(iterable):
1184 self.append(value)
1185 return self
1186
1187 _tidy(__iadd__)
1188 return __iadd__
1189
1190 def pop(fn):
1191 def pop(self, index=-1):
1192 __before_pop(self)
1193 item = fn(self, index)
1194 __del(self, item, None, index)
1195 return item
1196
1197 _tidy(pop)
1198 return pop
1199
1200 def clear(fn):
1201 def clear(self, index=-1):
1202 for item in self:
1203 __del(self, item, None, index)
1204 fn(self)
1205
1206 _tidy(clear)
1207 return clear
1208
1209 # __imul__ : not wrapping this. all members of the collection are already
1210 # present, so no need to fire appends... wrapping it with an explicit
1211 # decorator is still possible, so events on *= can be had if they're
1212 # desired. hard to imagine a use case for __imul__, though.
1213
1214 l = locals().copy()
1215 l.pop("_tidy")
1216 return l
1217
1218
1219def _dict_decorators() -> Dict[str, Callable[[_FN], _FN]]:
1220 """Tailored instrumentation wrappers for any dict-like mapping class."""
1221
1222 def _tidy(fn):
1223 fn._sa_instrumented = True
1224 fn.__doc__ = getattr(dict, fn.__name__).__doc__
1225
1226 def __setitem__(fn):
1227 def __setitem__(self, key, value, _sa_initiator=None):
1228 if key in self:
1229 __del(self, self[key], _sa_initiator, key)
1230 value = __set(self, value, _sa_initiator, key)
1231 fn(self, key, value)
1232
1233 _tidy(__setitem__)
1234 return __setitem__
1235
1236 def __delitem__(fn):
1237 def __delitem__(self, key, _sa_initiator=None):
1238 if key in self:
1239 __del(self, self[key], _sa_initiator, key)
1240 fn(self, key)
1241
1242 _tidy(__delitem__)
1243 return __delitem__
1244
1245 def clear(fn):
1246 def clear(self):
1247 for key in self:
1248 __del(self, self[key], None, key)
1249 fn(self)
1250
1251 _tidy(clear)
1252 return clear
1253
1254 def pop(fn):
1255 def pop(self, key, default=NO_ARG):
1256 __before_pop(self)
1257 _to_del = key in self
1258 if default is NO_ARG:
1259 item = fn(self, key)
1260 else:
1261 item = fn(self, key, default)
1262 if _to_del:
1263 __del(self, item, None, key)
1264 return item
1265
1266 _tidy(pop)
1267 return pop
1268
1269 def popitem(fn):
1270 def popitem(self):
1271 __before_pop(self)
1272 item = fn(self)
1273 __del(self, item[1], None, 1)
1274 return item
1275
1276 _tidy(popitem)
1277 return popitem
1278
1279 def setdefault(fn):
1280 def setdefault(self, key, default=None):
1281 if key not in self:
1282 self.__setitem__(key, default)
1283 return default
1284 else:
1285 value = self.__getitem__(key)
1286 if value is default:
1287 __set_wo_mutation(self, value, None)
1288
1289 return value
1290
1291 _tidy(setdefault)
1292 return setdefault
1293
1294 def update(fn):
1295 def update(self, __other=NO_ARG, **kw):
1296 if __other is not NO_ARG:
1297 if hasattr(__other, "keys"):
1298 for key in list(__other):
1299 if key not in self or self[key] is not __other[key]:
1300 self[key] = __other[key]
1301 else:
1302 __set_wo_mutation(self, __other[key], None)
1303 else:
1304 for key, value in __other:
1305 if key not in self or self[key] is not value:
1306 self[key] = value
1307 else:
1308 __set_wo_mutation(self, value, None)
1309 for key in kw:
1310 if key not in self or self[key] is not kw[key]:
1311 self[key] = kw[key]
1312 else:
1313 __set_wo_mutation(self, kw[key], None)
1314
1315 _tidy(update)
1316 return update
1317
1318 l = locals().copy()
1319 l.pop("_tidy")
1320 return l
1321
1322
1323_set_binop_bases = (set, frozenset)
1324
1325
1326def _set_binops_check_strict(self: Any, obj: Any) -> bool:
1327 """Allow only set, frozenset and self.__class__-derived
1328 objects in binops."""
1329 return isinstance(obj, _set_binop_bases + (self.__class__,))
1330
1331
1332def _set_decorators() -> Dict[str, Callable[[_FN], _FN]]:
1333 """Tailored instrumentation wrappers for any set-like class."""
1334
1335 def _tidy(fn):
1336 fn._sa_instrumented = True
1337 fn.__doc__ = getattr(set, fn.__name__).__doc__
1338
1339 def add(fn):
1340 def add(self, value, _sa_initiator=None):
1341 if value not in self:
1342 value = __set(self, value, _sa_initiator, NO_KEY)
1343 else:
1344 __set_wo_mutation(self, value, _sa_initiator)
1345 # testlib.pragma exempt:__hash__
1346 fn(self, value)
1347
1348 _tidy(add)
1349 return add
1350
1351 def discard(fn):
1352 def discard(self, value, _sa_initiator=None):
1353 # testlib.pragma exempt:__hash__
1354 if value in self:
1355 __del(self, value, _sa_initiator, NO_KEY)
1356 # testlib.pragma exempt:__hash__
1357 fn(self, value)
1358
1359 _tidy(discard)
1360 return discard
1361
1362 def remove(fn):
1363 def remove(self, value, _sa_initiator=None):
1364 # testlib.pragma exempt:__hash__
1365 if value in self:
1366 __del(self, value, _sa_initiator, NO_KEY)
1367 # testlib.pragma exempt:__hash__
1368 fn(self, value)
1369
1370 _tidy(remove)
1371 return remove
1372
1373 def pop(fn):
1374 def pop(self):
1375 __before_pop(self)
1376 item = fn(self)
1377 # for set in particular, we have no way to access the item
1378 # that will be popped before pop is called.
1379 __del(self, item, None, NO_KEY)
1380 return item
1381
1382 _tidy(pop)
1383 return pop
1384
1385 def clear(fn):
1386 def clear(self):
1387 for item in list(self):
1388 self.remove(item)
1389
1390 _tidy(clear)
1391 return clear
1392
1393 def update(fn):
1394 def update(self, value):
1395 for item in value:
1396 self.add(item)
1397
1398 _tidy(update)
1399 return update
1400
1401 def __ior__(fn):
1402 def __ior__(self, value):
1403 if not _set_binops_check_strict(self, value):
1404 return NotImplemented
1405 for item in value:
1406 self.add(item)
1407 return self
1408
1409 _tidy(__ior__)
1410 return __ior__
1411
1412 def difference_update(fn):
1413 def difference_update(self, value):
1414 for item in value:
1415 self.discard(item)
1416
1417 _tidy(difference_update)
1418 return difference_update
1419
1420 def __isub__(fn):
1421 def __isub__(self, value):
1422 if not _set_binops_check_strict(self, value):
1423 return NotImplemented
1424 for item in value:
1425 self.discard(item)
1426 return self
1427
1428 _tidy(__isub__)
1429 return __isub__
1430
1431 def intersection_update(fn):
1432 def intersection_update(self, other):
1433 want, have = self.intersection(other), set(self)
1434 remove, add = have - want, want - have
1435
1436 for item in remove:
1437 self.remove(item)
1438 for item in add:
1439 self.add(item)
1440
1441 _tidy(intersection_update)
1442 return intersection_update
1443
1444 def __iand__(fn):
1445 def __iand__(self, other):
1446 if not _set_binops_check_strict(self, other):
1447 return NotImplemented
1448 want, have = self.intersection(other), set(self)
1449 remove, add = have - want, want - have
1450
1451 for item in remove:
1452 self.remove(item)
1453 for item in add:
1454 self.add(item)
1455 return self
1456
1457 _tidy(__iand__)
1458 return __iand__
1459
1460 def symmetric_difference_update(fn):
1461 def symmetric_difference_update(self, other):
1462 want, have = self.symmetric_difference(other), set(self)
1463 remove, add = have - want, want - have
1464
1465 for item in remove:
1466 self.remove(item)
1467 for item in add:
1468 self.add(item)
1469
1470 _tidy(symmetric_difference_update)
1471 return symmetric_difference_update
1472
1473 def __ixor__(fn):
1474 def __ixor__(self, other):
1475 if not _set_binops_check_strict(self, other):
1476 return NotImplemented
1477 want, have = self.symmetric_difference(other), set(self)
1478 remove, add = have - want, want - have
1479
1480 for item in remove:
1481 self.remove(item)
1482 for item in add:
1483 self.add(item)
1484 return self
1485
1486 _tidy(__ixor__)
1487 return __ixor__
1488
1489 l = locals().copy()
1490 l.pop("_tidy")
1491 return l
1492
1493
1494class InstrumentedList(List[_T]):
1495 """An instrumented version of the built-in list."""
1496
1497
1498class InstrumentedSet(Set[_T]):
1499 """An instrumented version of the built-in set."""
1500
1501
1502class InstrumentedDict(Dict[_KT, _VT]):
1503 """An instrumented version of the built-in dict."""
1504
1505
1506__canned_instrumentation = cast(
1507 util.immutabledict[Any, _CollectionFactoryType],
1508 util.immutabledict(
1509 {
1510 list: InstrumentedList,
1511 set: InstrumentedSet,
1512 dict: InstrumentedDict,
1513 }
1514 ),
1515)
1516
1517__interfaces: util.immutabledict[
1518 Any,
1519 Tuple[
1520 Dict[str, str],
1521 Dict[str, Callable[..., Any]],
1522 ],
1523] = util.immutabledict(
1524 {
1525 list: (
1526 {
1527 "appender": "append",
1528 "remover": "remove",
1529 "iterator": "__iter__",
1530 },
1531 _list_decorators(),
1532 ),
1533 set: (
1534 {"appender": "add", "remover": "remove", "iterator": "__iter__"},
1535 _set_decorators(),
1536 ),
1537 # decorators are required for dicts and object collections.
1538 dict: ({"iterator": "values"}, _dict_decorators()),
1539 }
1540)
1541
1542
1543def __go(lcls):
1544 global keyfunc_mapping, mapped_collection
1545 global column_keyed_dict, column_mapped_collection
1546 global MappedCollection, KeyFuncDict
1547 global attribute_keyed_dict, attribute_mapped_collection
1548
1549 from .mapped_collection import keyfunc_mapping
1550 from .mapped_collection import column_keyed_dict
1551 from .mapped_collection import attribute_keyed_dict
1552 from .mapped_collection import KeyFuncDict
1553
1554 from .mapped_collection import mapped_collection
1555 from .mapped_collection import column_mapped_collection
1556 from .mapped_collection import attribute_mapped_collection
1557 from .mapped_collection import MappedCollection
1558
1559 # ensure instrumentation is associated with
1560 # these built-in classes; if a user-defined class
1561 # subclasses these and uses @internally_instrumented,
1562 # the superclass is otherwise not instrumented.
1563 # see [ticket:2406].
1564 _instrument_class(InstrumentedList)
1565 _instrument_class(InstrumentedSet)
1566 _instrument_class(KeyFuncDict)
1567
1568
1569__go(locals())