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