diff options
| author | Mike Bayer <mike_mp@zzzcomputing.com> | 2013-07-08 13:39:56 -0400 |
|---|---|---|
| committer | Mike Bayer <mike_mp@zzzcomputing.com> | 2013-07-08 13:39:56 -0400 |
| commit | 02a81707dc8b7c4d69551cad195fb16ca6955df1 (patch) | |
| tree | feda05ad7e0ce7bef057b9ee9d55d3273d8d008a /lib/sqlalchemy | |
| parent | db68ecff12f790fd129f03b8676b317fa17e5f28 (diff) | |
| download | sqlalchemy-02a81707dc8b7c4d69551cad195fb16ca6955df1.tar.gz | |
- create a new system where we can decorate an event method
with @_legacy_signature, will inspect incoming listener functions
to see if they match an older signature, will wrap into a newer sig
- add an event listen argument named=True, will send all args as
kw args so that event listeners can be written with **kw, any combination
of names
- add a doc system to events that writes out the various calling styles
for a given event, produces deprecation messages automatically.
a little concerned that it's a bit verbose but will look at it up
on RTD for awhile to get a feel.
- change the calling signature for bulk update/delete events - we have
the BulkUD object right there, and there's at least six or seven things
people might want to see, so just send the whole BulkUD in
[ticket:2775]
Diffstat (limited to 'lib/sqlalchemy')
| -rw-r--r-- | lib/sqlalchemy/event.py | 198 | ||||
| -rw-r--r-- | lib/sqlalchemy/events.py | 8 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/events.py | 64 | ||||
| -rw-r--r-- | lib/sqlalchemy/orm/persistence.py | 10 | ||||
| -rw-r--r-- | lib/sqlalchemy/util/__init__.py | 7 | ||||
| -rw-r--r-- | lib/sqlalchemy/util/compat.py | 10 | ||||
| -rw-r--r-- | lib/sqlalchemy/util/deprecations.py | 40 | ||||
| -rw-r--r-- | lib/sqlalchemy/util/langhelpers.py | 16 |
8 files changed, 309 insertions, 44 deletions
diff --git a/lib/sqlalchemy/event.py b/lib/sqlalchemy/event.py index bfd027ead..64ae49976 100644 --- a/lib/sqlalchemy/event.py +++ b/lib/sqlalchemy/event.py @@ -6,6 +6,8 @@ """Base event API.""" +from __future__ import absolute_import + from . import util, exc from itertools import chain import weakref @@ -77,6 +79,15 @@ def remove(target, identifier, fn): tgt.dispatch._remove(identifier, tgt, fn) return +def _legacy_signature(since, argnames, converter=None): + def leg(fn): + if not hasattr(fn, '_legacy_signatures'): + fn._legacy_signatures = [] + fn._legacy_signatures.append((since, argnames, converter)) + return fn + return leg + + _registrars = util.defaultdict(list) @@ -189,7 +200,7 @@ def _create_dispatcher_class(cls, classname, bases, dict_): for k in dict_: if _is_event_name(k): - setattr(dispatch_cls, k, _DispatchDescriptor(dict_[k])) + setattr(dispatch_cls, k, _DispatchDescriptor(cls, dict_[k])) _registrars[k].append(cls) @@ -217,12 +228,16 @@ class Events(util.with_metaclass(_EventMeta, object)): return None @classmethod - def _listen(cls, target, identifier, fn, propagate=False, insert=False): + def _listen(cls, target, identifier, fn, propagate=False, insert=False, + named=False): + dispatch_descriptor = getattr(target.dispatch, identifier) + fn = dispatch_descriptor._adjust_fn_spec(fn, named) + if insert: - getattr(target.dispatch, identifier).\ + dispatch_descriptor.\ for_modify(target.dispatch).insert(fn, target, propagate) else: - getattr(target.dispatch, identifier).\ + dispatch_descriptor.\ for_modify(target.dispatch).append(fn, target, propagate) @classmethod @@ -237,12 +252,169 @@ class Events(util.with_metaclass(_EventMeta, object)): class _DispatchDescriptor(object): """Class-level attributes on :class:`._Dispatch` classes.""" - def __init__(self, fn): + def __init__(self, parent_dispatch_cls, fn): self.__name__ = fn.__name__ - self.__doc__ = fn.__doc__ + argspec = util.inspect_getargspec(fn) + self.arg_names = argspec.args[1:] + self.has_kw = bool(argspec.keywords) + self.legacy_signatures = list(reversed( + sorted( + getattr(fn, '_legacy_signatures', []), + key=lambda s: s[0] + ) + )) + self.__doc__ = fn.__doc__ = self._augment_fn_docs(parent_dispatch_cls, fn) + self._clslevel = weakref.WeakKeyDictionary() self._empty_listeners = weakref.WeakKeyDictionary() + def _adjust_fn_spec(self, fn, named): + argspec = util.get_callable_argspec(fn, no_self=True) + if named: + fn = self._wrap_fn_for_kw(fn) + fn = self._wrap_fn_for_legacy(fn, argspec) + return fn + + def _wrap_fn_for_kw(self, fn): + def wrap_kw(*args, **kw): + argdict = dict(zip(self.arg_names, args)) + argdict.update(kw) + return fn(**argdict) + return wrap_kw + + def _wrap_fn_for_legacy(self, fn, argspec): + for since, argnames, conv in self.legacy_signatures: + if argnames[-1] == "**kw": + has_kw = True + argnames = argnames[0:-1] + else: + has_kw = False + + if len(argnames) == len(argspec.args) \ + and has_kw is bool(argspec.keywords): + + if conv: + assert not has_kw + def wrap_leg(*args): + return fn(*conv(*args)) + else: + def wrap_leg(*args, **kw): + argdict = dict(zip(self.arg_names, args)) + args = [argdict[name] for name in argnames] + if has_kw: + return fn(*args, **kw) + else: + return fn(*args) + return wrap_leg + else: + return fn + + def _indent(self, text, indent): + return "\n".join( + indent + line + for line in text.split("\n") + ) + + def _standard_listen_example(self, sample_target, fn): + example_kw_arg = self._indent( + "\n".join( + "%(arg)s = kw['%(arg)s']" % {"arg": arg} + for arg in self.arg_names[0:2] + ), + " ") + if self.legacy_signatures: + current_since = max(since for since, args, conv in self.legacy_signatures) + else: + current_since = None + text = ( + "from sqlalchemy import event\n\n" + "# standard decorator style%(current_since)s\n" + "@event.listens_for(%(sample_target)s, '%(event_name)s')\n" + "def receive_%(event_name)s(%(named_event_arguments)s%(has_kw_arguments)s):\n" + " \"listen for the '%(event_name)s' event\"\n" + "\n # ... (event handling logic) ...\n" + ) + + if len(self.arg_names) > 2: + text += ( + + "\n# named argument style (new in 0.9)\n" + "@event.listens_for(%(sample_target)s, '%(event_name)s', named=True)\n" + "def receive_%(event_name)s(**kw):\n" + " \"listen for the '%(event_name)s' event\"\n" + "%(example_kw_arg)s\n" + "\n # ... (event handling logic) ...\n" + ) + + text %= { + "current_since": " (arguments as of %s)" % + current_since if current_since else "", + "event_name": fn.__name__, + "has_kw_arguments": " **kw" if self.has_kw else "", + "named_event_arguments": ", ".join(self.arg_names), + "example_kw_arg": example_kw_arg, + "sample_target": sample_target + } + return text + + def _legacy_listen_examples(self, sample_target, fn): + text = "" + for since, args, conv in self.legacy_signatures: + text += ( + "\n# legacy calling style (pre-%(since)s)\n" + "@event.listens_for(%(sample_target)s, '%(event_name)s')\n" + "def receive_%(event_name)s(%(named_event_arguments)s%(has_kw_arguments)s):\n" + " \"listen for the '%(event_name)s' event\"\n" + "\n # ... (event handling logic) ...\n" % { + "since": since, + "event_name": fn.__name__, + "has_kw_arguments": " **kw" if self.has_kw else "", + "named_event_arguments": ", ".join(args), + "sample_target": sample_target + } + ) + return text + + def _version_signature_changes(self): + since, args, conv = self.legacy_signatures[0] + return ( + "\n.. versionchanged:: %(since)s\n" + " The ``%(event_name)s`` event now accepts the \n" + " arguments ``%(named_event_arguments)s%(has_kw_arguments)s``.\n" + " Listener functions which accept the previous argument \n" + " signature(s) listed above will be automatically \n" + " adapted to the new signature." % { + "since": since, + "event_name": self.__name__, + "named_event_arguments": ", ".join(self.arg_names), + "has_kw_arguments": ", **kw" if self.has_kw else "" + } + ) + + def _augment_fn_docs(self, parent_dispatch_cls, fn): + header = ".. container:: event_signatures\n\n"\ + " Example argument forms::\n"\ + "\n" + + sample_target = getattr(parent_dispatch_cls, "_target_class_doc", "obj") + text = ( + header + + self._indent( + self._standard_listen_example(sample_target, fn), + " " * 8) + ) + if self.legacy_signatures: + text += self._indent( + self._legacy_listen_examples(sample_target, fn), + " " * 8) + + text += self._version_signature_changes() + + return util.inject_docstring_text(fn.__doc__, + text, + 1 + ) + def _contains(self, cls, evt): return cls in self._clslevel and \ evt in self._clslevel[cls] @@ -324,8 +496,11 @@ class _DispatchDescriptor(object): obj.__dict__[self.__name__] = ret return ret +class _HasParentDispatchDescriptor(object): + def _adjust_fn_spec(self, fn, named): + return self.parent._adjust_fn_spec(fn, named) -class _EmptyListener(object): +class _EmptyListener(_HasParentDispatchDescriptor): """Serves as a class-level interface to the events served by a _DispatchDescriptor, when there are no instance-level events present. @@ -337,12 +512,13 @@ class _EmptyListener(object): def __init__(self, parent, target_cls): if target_cls not in parent._clslevel: parent.update_subclass(target_cls) - self.parent = parent + self.parent = parent # _DispatchDescriptor self.parent_listeners = parent._clslevel[target_cls] self.name = parent.__name__ self.propagate = frozenset() self.listeners = () + def for_modify(self, obj): """Return an event collection which can be modified. @@ -380,7 +556,7 @@ class _EmptyListener(object): __nonzero__ = __bool__ -class _CompoundListener(object): +class _CompoundListener(_HasParentDispatchDescriptor): _exec_once = False def exec_once(self, *args, **kw): @@ -432,6 +608,7 @@ class _ListenerCollection(_CompoundListener): if target_cls not in parent._clslevel: parent.update_subclass(target_cls) self.parent_listeners = parent._clslevel[target_cls] + self.parent = parent self.name = parent.__name__ self.listeners = [] self.propagate = set() @@ -520,6 +697,9 @@ class _JoinedListener(_CompoundListener): # each time. less performant. self.listeners = list(getattr(self.parent, self.name)) + def _adjust_fn_spec(self, fn, named): + return self.local._adjust_fn_spec(fn, named) + def for_modify(self, obj): self.local = self.parent_listeners = self.local.for_modify(obj) return self diff --git a/lib/sqlalchemy/events.py b/lib/sqlalchemy/events.py index 7f11232ac..4fb997b9c 100644 --- a/lib/sqlalchemy/events.py +++ b/lib/sqlalchemy/events.py @@ -70,6 +70,8 @@ class DDLEvents(event.Events): """ + _target_class_doc = "SomeSchemaClassOrObject" + def before_create(self, target, connection, **kw): """Called before CREATE statments are emitted. @@ -266,6 +268,8 @@ class PoolEvents(event.Events): """ + _target_class_doc = "SomeEngineOrPool" + @classmethod def _accept_with(cls, target): if isinstance(target, type): @@ -443,6 +447,8 @@ class ConnectionEvents(event.Events): """ + _target_class_doc = "SomeEngine" + @classmethod def _listen(cls, target, identifier, fn, retval=False): target._has_events = True @@ -753,7 +759,7 @@ class ConnectionEvents(event.Events): :param conn: :class:`.Connection` object """ - def savepoint(self, conn, name=None): + def savepoint(self, conn, name): """Intercept savepoint() events. :param conn: :class:`.Connection` object diff --git a/lib/sqlalchemy/orm/events.py b/lib/sqlalchemy/orm/events.py index cea07bcf0..97019bb4e 100644 --- a/lib/sqlalchemy/orm/events.py +++ b/lib/sqlalchemy/orm/events.py @@ -42,6 +42,8 @@ class InstrumentationEvents(event.Events): """ + _target_class_doc = "SomeBaseClass" + @classmethod def _accept_with(cls, target): # TODO: there's no coverage for this @@ -151,6 +153,9 @@ class InstanceEvents(event.Events): object, rather than the mapped instance itself. """ + + _target_class_doc = "SomeMappedClass" + @classmethod def _accept_with(cls, target): if isinstance(target, orm.instrumentation.ClassManager): @@ -450,6 +455,8 @@ class MapperEvents(event.Events): """ + _target_class_doc = "SomeMappedClass" + @classmethod def _accept_with(cls, target): if target is orm.mapper: @@ -1083,6 +1090,9 @@ class SessionEvents(event.Events): globally. """ + + _target_class_doc = "SomeSessionOrFactory" + @classmethod def _accept_with(cls, target): if isinstance(target, orm.scoped_session): @@ -1382,31 +1392,55 @@ class SessionEvents(event.Events): """ - def after_bulk_update(self, session, query, query_context, result): + @event._legacy_signature("0.9", + ["session", "query", "query_context", "result"], + lambda update_context: ( + update_context.session, + update_context.query, + update_context.context, + update_context.result)) + def after_bulk_update(self, update_context): """Execute after a bulk update operation to the session. This is called as a result of the :meth:`.Query.update` method. - :param query: the :class:`.Query` object that this update operation was - called upon. - :param query_context: The :class:`.QueryContext` object, corresponding - to the invocation of an ORM query. - :param result: the :class:`.ResultProxy` returned as a result of the - bulk UPDATE operation. + :param update_context: an "update context" object which contains + details about the update, including these attributes: + + * ``session`` - the :class:`.Session` involved + * ``query`` -the :class:`.Query` object that this update operation was + called upon. + * ``context`` The :class:`.QueryContext` object, corresponding + to the invocation of an ORM query. + * ``result`` the :class:`.ResultProxy` returned as a result of the + bulk UPDATE operation. + """ - def after_bulk_delete(self, session, query, query_context, result): + @event._legacy_signature("0.9", + ["session", "query", "query_context", "result"], + lambda delete_context: ( + delete_context.session, + delete_context.query, + delete_context.context, + delete_context.result)) + def after_bulk_delete(self, delete_context): """Execute after a bulk delete operation to the session. This is called as a result of the :meth:`.Query.delete` method. - :param query: the :class:`.Query` object that this update operation was - called upon. - :param query_context: The :class:`.QueryContext` object, corresponding - to the invocation of an ORM query. - :param result: the :class:`.ResultProxy` returned as a result of the - bulk DELETE operation. + :param delete_context: a "delete context" object which contains + details about the update, including these attributes: + + * ``session`` - the :class:`.Session` involved + * ``query`` -the :class:`.Query` object that this update operation was + called upon. + * ``context`` The :class:`.QueryContext` object, corresponding + to the invocation of an ORM query. + * ``result`` the :class:`.ResultProxy` returned as a result of the + bulk DELETE operation. + """ @@ -1468,6 +1502,8 @@ class AttributeEvents(event.Events): """ + _target_class_doc = "SomeClass.some_attribute" + @classmethod def _accept_with(cls, target): # TODO: coverage diff --git a/lib/sqlalchemy/orm/persistence.py b/lib/sqlalchemy/orm/persistence.py index 944623b07..44da88118 100644 --- a/lib/sqlalchemy/orm/persistence.py +++ b/lib/sqlalchemy/orm/persistence.py @@ -798,6 +798,10 @@ class BulkUD(object): def __init__(self, query): self.query = query.enable_eagerloads(False) + @property + def session(self): + return self.query.session + @classmethod def _factory(cls, lookup, synchronize_session, *arg): try: @@ -915,8 +919,7 @@ class BulkUpdate(BulkUD): def _do_post(self): session = self.query.session - session.dispatch.after_bulk_update(session, self.query, - self.context, self.result) + session.dispatch.after_bulk_update(self) class BulkDelete(BulkUD): @@ -944,8 +947,7 @@ class BulkDelete(BulkUD): def _do_post(self): session = self.query.session - session.dispatch.after_bulk_delete(session, self.query, - self.context, self.result) + session.dispatch.after_bulk_delete(self) class BulkUpdateEvaluate(BulkEvaluate, BulkUpdate): diff --git a/lib/sqlalchemy/util/__init__.py b/lib/sqlalchemy/util/__init__.py index 739caefe0..5d9c4d49e 100644 --- a/lib/sqlalchemy/util/__init__.py +++ b/lib/sqlalchemy/util/__init__.py @@ -10,7 +10,7 @@ from .compat import callable, cmp, reduce, \ raise_from_cause, text_type, string_types, int_types, binary_type, \ quote_plus, with_metaclass, print_, itertools_filterfalse, u, ue, b,\ unquote_plus, b64decode, b64encode, byte_buffer, itertools_filter,\ - StringIO + StringIO, inspect_getargspec from ._collections import KeyedTuple, ImmutableContainer, immutabledict, \ Properties, OrderedProperties, ImmutableProperties, OrderedDict, \ @@ -30,10 +30,11 @@ from .langhelpers import iterate_attributes, class_hierarchy, \ duck_type_collection, assert_arg_type, symbol, dictlike_iteritems,\ classproperty, set_creation_order, warn_exception, warn, NoneType,\ constructor_copy, methods_equivalent, chop_traceback, asint,\ - generic_repr, counter, PluginLoader, hybridmethod, safe_reraise + generic_repr, counter, PluginLoader, hybridmethod, safe_reraise,\ + get_callable_argspec from .deprecations import warn_deprecated, warn_pending_deprecation, \ - deprecated, pending_deprecation + deprecated, pending_deprecation, inject_docstring_text # things that used to be not always available, # but are now as of current support Python versions diff --git a/lib/sqlalchemy/util/compat.py b/lib/sqlalchemy/util/compat.py index d866534ab..a89762b4e 100644 --- a/lib/sqlalchemy/util/compat.py +++ b/lib/sqlalchemy/util/compat.py @@ -22,7 +22,7 @@ pypy = hasattr(sys, 'pypy_version_info') win32 = sys.platform.startswith('win') cpython = not pypy and not jython # TODO: something better for this ? - +import collections next = next if py3k: @@ -33,6 +33,9 @@ else: except ImportError: import pickle +ArgSpec = collections.namedtuple("ArgSpec", + ["args", "varargs", "keywords", "defaults"]) + if py3k: import builtins @@ -43,6 +46,10 @@ if py3k: from io import BytesIO as byte_buffer + def inspect_getargspec(func): + return ArgSpec( + *inspect_getfullargspec(func)[0:4] + ) string_types = str, binary_type = bytes @@ -87,6 +94,7 @@ if py3k: else: from inspect import getargspec as inspect_getfullargspec + inspect_getargspec = inspect_getfullargspec from urllib import quote_plus, unquote_plus from urlparse import parse_qsl import ConfigParser as configparser diff --git a/lib/sqlalchemy/util/deprecations.py b/lib/sqlalchemy/util/deprecations.py index e0dc168db..c315d2da6 100644 --- a/lib/sqlalchemy/util/deprecations.py +++ b/lib/sqlalchemy/util/deprecations.py @@ -107,17 +107,37 @@ def _decorate_with_warning(func, wtype, message, docstring_header=None): doc = func.__doc__ is not None and func.__doc__ or '' if docstring_header is not None: docstring_header %= dict(func=func.__name__) - docs = doc and doc.expandtabs().split('\n') or [] - indent = '' - for line in docs[1:]: - text = line.lstrip() - if text: - indent = line[0:len(line) - len(text)] - break - point = min(len(docs), 1) - docs.insert(point, '\n' + indent + docstring_header.rstrip()) - doc = '\n'.join(docs) + + doc = inject_docstring_text(doc, docstring_header, 1) decorated = warned(func) decorated.__doc__ = doc return decorated + +import textwrap + +def _dedent_docstring(text): + split_text = text.split("\n", 1) + if len(split_text) == 1: + return text + else: + firstline, remaining = split_text + if not firstline.startswith(" "): + return firstline + "\n" + textwrap.dedent(remaining) + else: + return textwrap.dedent(text) + +def inject_docstring_text(doctext, injecttext, pos): + doctext = _dedent_docstring(doctext or "") + lines = doctext.split('\n') + injectlines = textwrap.dedent(injecttext).split("\n") + if injectlines[0]: + injectlines.insert(0, "") + + blanks = [num for num, line in enumerate(lines) if not line.strip()] + blanks.insert(0, 0) + + inject_pos = blanks[min(pos, len(blanks) - 1)] + + lines = lines[0:inject_pos] + injectlines + lines[inject_pos:] + return "\n".join(lines) diff --git a/lib/sqlalchemy/util/langhelpers.py b/lib/sqlalchemy/util/langhelpers.py index 1ff868e01..c91178a75 100644 --- a/lib/sqlalchemy/util/langhelpers.py +++ b/lib/sqlalchemy/util/langhelpers.py @@ -211,8 +211,20 @@ def get_func_kwargs(func): """ - return inspect.getargspec(func)[0] - + return compat.inspect_getargspec(func)[0] + +def get_callable_argspec(fn, no_self=False): + if isinstance(fn, types.FunctionType): + return compat.inspect_getargspec(fn) + elif isinstance(fn, types.MethodType) and no_self: + spec = compat.inspect_getargspec(fn.__func__) + return compat.ArgSpec(spec.args[1:], spec.varargs, spec.keywords, spec.defaults) + elif hasattr(fn, '__func__'): + return compat.inspect_getargspec(fn.__func__) + elif hasattr(fn, '__call__'): + return get_callable_argspec(fn.__call__) + else: + raise ValueError("Can't inspect function: %s" % fn) def format_argspec_plus(fn, grouped=True): """Returns a dictionary of formatted, introspected function arguments. |
