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