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