Merge lp:~cjwatson/storm/refactor-exception-wrapping into lp:storm

Proposed by Colin Watson
Status: Merged
Merged at revision: 509
Proposed branch: lp:~cjwatson/storm/refactor-exception-wrapping
Merge into: lp:storm
Diff against target: 423 lines (+228/-31)
6 files modified
NEWS (+4/-0)
storm/database.py (+132/-1)
storm/databases/postgres.py (+28/-7)
storm/databases/sqlite.py (+15/-9)
storm/exceptions.py (+46/-14)
tests/database.py (+3/-0)
To merge this branch: bzr merge lp:~cjwatson/storm/refactor-exception-wrapping
Reviewer Review Type Date Requested Status
Simon Poirier (community) Approve
Review via email: mp+369319@code.launchpad.net

Commit message

Wrap DB-API exceptions rather than injecting virtual base classes.

Description of the change

Re-raise DB-API exceptions wrapped in exception types that inherit from both StormError and the appropriate DB-API exception type, rather than injecting virtual base classes. This preserves existing exception handling in applications while also being a viable approach in Python 3.

Once I managed to get the right wrappers in place, this ended up being surprisingly simple, which makes me feel good about it (my first draft had many more explicit calls to wrap_exceptions and so felt much more fragile). The main difficulty was in creating just the right wrapper exception types to maintain API compatibility without making the code painfully repetitive, and I finally got that to fall into place today.

I'm running the full Launchpad test suite over this at the moment, though it looks clean so far and I've already found and fixed some problems via some of its most Storm-sensitive tests.

To post a comment you must log in.
509. By Colin Watson

Make wrapper exceptions look slightly more like the DB-API originals.

510. By Colin Watson

Improve comment.

Revision history for this message
Colin Watson (cjwatson) wrote :

I've done a full successful Launchpad test run with this patch. The only change I had to make to Launchpad was to adjust LaunchpadDatabase.__init__, which is just because it's subclassing Postgres while (for unrelated reasons) not running the superclass constructor, so as a consequence it has to keep up with changes to the superclass constructor, which I think is fine.

Revision history for this message
Simon Poirier (simpoir) wrote :

+1
I ran the full landscape test suite against this without any apparent regression.

review: Approve
511. By Colin Watson

Use superclass __setattr__ rather than changing __dict__ directly.

512. By Colin Watson

Use more neutral language.

Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'NEWS'
--- NEWS 2019-06-05 11:41:07 +0000
+++ NEWS 2019-07-02 15:55:56 +0000
@@ -8,6 +8,10 @@
8 package, which can apply patches "in parallel" against a set of stores. See8 package, which can apply patches "in parallel" against a set of stores. See
9 the module docstring for more information.9 the module docstring for more information.
10- Added tox testing support.10- Added tox testing support.
11- Re-raise DB-API exceptions wrapped in exception types that inherit from
12 both StormError and the appropriate DB-API exception type, rather than
13 injecting virtual base classes. This preserves existing exception handling
14 in applications while also being a viable approach in Python 3.
1115
12Bug fixes16Bug fixes
13---------17---------
1418
=== modified file 'storm/database.py'
--- storm/database.py 2019-06-05 11:41:07 +0000
+++ storm/database.py 2019-07-02 15:55:56 +0000
@@ -27,6 +27,12 @@
2727
28from __future__ import print_function28from __future__ import print_function
2929
30try:
31 from collections.abc import Callable
32except ImportError:
33 from collections import Callable
34from functools import wraps
35
30from storm.expr import Expr, State, compile36from storm.expr import Expr, State, compile
31# Circular import: imported at the end of the module.37# Circular import: imported at the end of the module.
32# from storm.tracer import trace38# from storm.tracer import trace
@@ -34,7 +40,7 @@
34from storm.xid import Xid40from storm.xid import Xid
35from storm.exceptions import (41from storm.exceptions import (
36 ClosedError, ConnectionBlockedError, DatabaseError, DisconnectionError,42 ClosedError, ConnectionBlockedError, DatabaseError, DisconnectionError,
37 Error, ProgrammingError)43 Error, ProgrammingError, wrap_exceptions)
38from storm.uri import URI44from storm.uri import URI
39import storm45import storm
4046
@@ -163,6 +169,78 @@
163 return row169 return row
164170
165171
172class CursorWrapper(object):
173 """A DB-API cursor, wrapping exceptions as StormError instances."""
174
175 def __init__(self, cursor, database):
176 super(CursorWrapper, self).__setattr__('_cursor', cursor)
177 super(CursorWrapper, self).__setattr__('_database', database)
178
179 def __getattr__(self, name):
180 attr = getattr(self._cursor, name)
181 if isinstance(attr, Callable):
182 @wraps(attr)
183 def wrapper(*args, **kwargs):
184 with wrap_exceptions(self._database):
185 return attr(*args, **kwargs)
186
187 return wrapper
188 else:
189 return attr
190
191 def __setattr__(self, name, value):
192 return setattr(self._cursor, name, value)
193
194 def __iter__(self):
195 with wrap_exceptions(self._database):
196 for item in self._cursor:
197 yield item
198
199 def __enter__(self):
200 return self
201
202 def __exit__(self, type_, value, tb):
203 with wrap_exceptions(self._database):
204 self.close()
205
206
207class ConnectionWrapper(object):
208 """A DB-API connection, wrapping exceptions as StormError instances."""
209
210 def __init__(self, connection, database):
211 self.__dict__['_connection'] = connection
212 self.__dict__['_database'] = database
213
214 def __getattr__(self, name):
215 attr = getattr(self._connection, name)
216 if isinstance(attr, Callable):
217 @wraps(attr)
218 def wrapper(*args, **kwargs):
219 with wrap_exceptions(self._database):
220 return attr(*args, **kwargs)
221
222 return wrapper
223 else:
224 return attr
225
226 def __setattr__(self, name, value):
227 return setattr(self._connection, name, value)
228
229 def __enter__(self):
230 return self
231
232 def __exit__(self, type_, value, tb):
233 with wrap_exceptions(self._database):
234 if type_ is None and value is None and tb is None:
235 self.commit()
236 else:
237 self.rollback()
238
239 def cursor(self):
240 with wrap_exceptions(self._database):
241 return CursorWrapper(self._connection.cursor(), self._database)
242
243
166class Connection(object):244class Connection(object):
167 """A connection to a database.245 """A connection to a database.
168246
@@ -483,6 +561,7 @@
483561
484 def __init__(self, uri=None):562 def __init__(self, uri=None):
485 self._uri = uri563 self._uri = uri
564 self._exception_types = {}
486565
487 def get_uri(self):566 def get_uri(self):
488 """Return the URI object this database was created with."""567 """Return the URI object this database was created with."""
@@ -512,6 +591,58 @@
512 """591 """
513 raise NotImplementedError592 raise NotImplementedError
514593
594 @property
595 def _exception_module(self):
596 """The module where appropriate DB-API exception types are defined.
597
598 Subclasses should set this if they support re-raising DB-API
599 exceptions as StormError instances.
600 """
601 return None
602
603 def _make_combined_exception_type(self, wrapper_type, dbapi_type):
604 """Make a combined exception based on both DB-API and Storm.
605
606 Storm historically defined its own exception types as ABCs and
607 registered the DB-API exception types as virtual subclasses.
608 However, this doesn't work properly in Python 3
609 (https://bugs.python.org/issue12029). Instead, we create and cache
610 subclass-specific exception types that inherit from both StormError
611 and the DB-API exception type, allowing code that catches either
612 StormError (or subclasses) or the specific DB-API exceptions to keep
613 working.
614
615 @type wrapper_type: L{type}
616 @param wrapper_type: The type of the wrapper exception to create; a
617 subclass of L{StormError}.
618 @type dbapi_type: L{type}
619 @param dbapi_type: The type of the DB-API exception.
620
621 @return: The combined exception type.
622 """
623 if wrapper_type not in self._exception_types:
624 self._exception_types[wrapper_type] = type(
625 dbapi_type.__name__, (dbapi_type, wrapper_type), {})
626 return self._exception_types[wrapper_type]
627
628 def _wrap_exception(self, wrapper_type, exception):
629 """Wrap a DB-API exception as a StormError instance.
630
631 This constructs a wrapper exception with the same C{args} as the
632 DB-API exception. Subclasses may override this to set additional
633 attributes on the wrapper exception.
634
635 @type wrapper_type: L{type}
636 @param wrapper_type: The type of the wrapper exception to create; a
637 subclass of L{StormError}.
638 @type exception: L{Exception}
639 @param exception: The DB-API exception to wrap.
640
641 @return: The wrapped exception; an instance of L{StormError}.
642 """
643 return self._make_combined_exception_type(
644 wrapper_type, exception.__class__)(*exception.args)
645
515646
516def convert_param_marks(statement, from_param_mark, to_param_mark):647def convert_param_marks(statement, from_param_mark, to_param_mark):
517 # TODO: Add support for $foo$bar$foo$ literals.648 # TODO: Add support for $foo$bar$foo$ literals.
518649
=== modified file 'storm/databases/postgres.py'
--- storm/databases/postgres.py 2019-06-05 11:41:07 +0000
+++ storm/databases/postgres.py 2019-07-02 15:55:56 +0000
@@ -50,14 +50,13 @@
50from storm.variables import (50from storm.variables import (
51 Variable, ListVariable, JSONVariable as BaseJSONVariable)51 Variable, ListVariable, JSONVariable as BaseJSONVariable)
52from storm.properties import SimpleProperty52from storm.properties import SimpleProperty
53from storm.database import Database, Connection, Result53from storm.database import Database, Connection, ConnectionWrapper, Result
54from storm.exceptions import (54from storm.exceptions import (
55 install_exceptions, DatabaseError, DatabaseModuleError, InterfaceError,55 DatabaseError, DatabaseModuleError, InterfaceError,
56 OperationalError, ProgrammingError, TimeoutError, Error)56 OperationalError, ProgrammingError, TimeoutError, Error, wrap_exceptions)
57from storm.tracer import TimeoutTracer57from storm.tracer import TimeoutTracer
5858
5959
60install_exceptions(psycopg2)
61compile = compile.create_child()60compile = compile.create_child()
6261
6362
@@ -366,6 +365,8 @@
366365
367 connection_factory = PostgresConnection366 connection_factory = PostgresConnection
368367
368 _exception_module = psycopg2
369
369 # An integer representing the server version. If the server does370 # An integer representing the server version. If the server does
370 # not support the server_version_num variable, this will be set to371 # not support the server_version_num variable, this will be set to
371 # 0. In practice, this means the variable will be 0 or greater372 # 0. In practice, this means the variable will be 0 or greater
@@ -397,15 +398,31 @@
397 "'autocommit', 'serializable', 'read-committed'" %398 "'autocommit', 'serializable', 'read-committed'" %
398 (isolation,))399 (isolation,))
399400
400 def raw_connect(self):401 _psycopg_error_attributes = ("pgerror", "pgcode", "cursor", "diag")
401 raw_connection = psycopg2.connect(self._dsn)402
403 def _make_combined_exception_type(self, wrapper_type, dbapi_type):
404 combined_type = super(Postgres, self)._make_combined_exception_type(
405 wrapper_type, dbapi_type)
406 for name in self._psycopg_error_attributes:
407 setattr(combined_type, name, lambda err: getattr(err, "_" + name))
408 return combined_type
409
410 def _wrap_exception(self, wrapper_type, exception):
411 wrapped = super(Postgres, self)._wrap_exception(
412 wrapper_type, exception)
413 for name in self._psycopg_error_attributes:
414 setattr(wrapped, "_" + name, getattr(exception, name))
415 return wrapped
416
417 def _raw_connect(self):
418 raw_connection = ConnectionWrapper(psycopg2.connect(self._dsn), self)
402419
403 if self._version is None:420 if self._version is None:
404 cursor = raw_connection.cursor()421 cursor = raw_connection.cursor()
405422
406 try:423 try:
407 cursor.execute("SHOW server_version_num")424 cursor.execute("SHOW server_version_num")
408 except psycopg2.ProgrammingError:425 except ProgrammingError:
409 self._version = 0426 self._version = 0
410 else:427 else:
411 self._version = int(cursor.fetchone()[0])428 self._version = int(cursor.fetchone()[0])
@@ -415,6 +432,10 @@
415 raw_connection.set_isolation_level(self._isolation)432 raw_connection.set_isolation_level(self._isolation)
416 return raw_connection433 return raw_connection
417434
435 def raw_connect(self):
436 with wrap_exceptions(self):
437 return self._raw_connect()
438
418439
419create_from_uri = Postgres440create_from_uri = Postgres
420441
421442
=== modified file 'storm/databases/sqlite.py'
--- storm/databases/sqlite.py 2019-06-07 12:17:35 +0000
+++ storm/databases/sqlite.py 2019-07-02 15:55:56 +0000
@@ -35,16 +35,14 @@
35 sqlite = dummy35 sqlite = dummy
3636
37from storm.variables import Variable, RawStrVariable37from storm.variables import Variable, RawStrVariable
38from storm.database import Database, Connection, Result38from storm.database import Database, Connection, ConnectionWrapper, Result
39from storm.exceptions import install_exceptions, DatabaseModuleError39from storm.exceptions import (
40 DatabaseModuleError, OperationalError, wrap_exceptions)
40from storm.expr import (41from storm.expr import (
41 Insert, Select, SELECT, Undef, SQLRaw, Union, Except, Intersect,42 Insert, Select, SELECT, Undef, SQLRaw, Union, Except, Intersect,
42 compile, compile_insert, compile_select)43 compile, compile_insert, compile_select)
4344
4445
45install_exceptions(sqlite)
46
47
48compile = compile.create_child()46compile = compile.create_child()
4947
50@compile.when(Select)48@compile.when(Select)
@@ -160,7 +158,7 @@
160 while True:158 while True:
161 try:159 try:
162 return Connection.raw_execute(self, statement, params)160 return Connection.raw_execute(self, statement, params)
163 except sqlite.OperationalError as e:161 except OperationalError as e:
164 if str(e) != "database is locked":162 if str(e) != "database is locked":
165 raise163 raise
166 elif now() - started < self._database._timeout:164 elif now() - started < self._database._timeout:
@@ -180,6 +178,8 @@
180178
181 connection_factory = SQLiteConnection179 connection_factory = SQLiteConnection
182180
181 _exception_module = sqlite
182
183 def __init__(self, uri):183 def __init__(self, uri):
184 super(SQLite, self).__init__(uri)184 super(SQLite, self).__init__(uri)
185 if sqlite is dummy:185 if sqlite is dummy:
@@ -190,10 +190,12 @@
190 self._journal_mode = uri.options.get("journal_mode")190 self._journal_mode = uri.options.get("journal_mode")
191 self._foreign_keys = uri.options.get("foreign_keys")191 self._foreign_keys = uri.options.get("foreign_keys")
192192
193 def raw_connect(self):193 def _raw_connect(self):
194 # See the story at the end to understand why we set isolation_level.194 # See the story at the end to understand why we set isolation_level.
195 raw_connection = sqlite.connect(self._filename, timeout=self._timeout,195 raw_connection = ConnectionWrapper(
196 isolation_level=None)196 sqlite.connect(
197 self._filename, timeout=self._timeout, isolation_level=None),
198 self)
197 if self._synchronous is not None:199 if self._synchronous is not None:
198 raw_connection.execute("PRAGMA synchronous = %s" %200 raw_connection.execute("PRAGMA synchronous = %s" %
199 (self._synchronous,))201 (self._synchronous,))
@@ -208,6 +210,10 @@
208210
209 return raw_connection211 return raw_connection
210212
213 def raw_connect(self):
214 with wrap_exceptions(self):
215 return self._raw_connect()
216
211217
212create_from_uri = SQLite218create_from_uri = SQLite
213219
214220
=== modified file 'storm/exceptions.py'
--- storm/exceptions.py 2019-06-07 16:35:06 +0000
+++ storm/exceptions.py 2019-07-02 15:55:56 +0000
@@ -20,12 +20,14 @@
20#20#
21from __future__ import print_function21from __future__ import print_function
2222
23from abc import ABCMeta23from contextlib import contextmanager
24import types24import sys
25
26import six
2527
2628
27class StormError(Exception):29class StormError(Exception):
28 __metaclass__ = ABCMeta30 pass
2931
3032
31class CompileError(StormError):33class CompileError(StormError):
@@ -140,14 +142,44 @@
140 """Raised when an attempt is made to use a blocked connection."""142 """Raised when an attempt is made to use a blocked connection."""
141143
142144
143def install_exceptions(module):145# More generic exceptions must come later. For convenience, use the order
144 for exception in (Error, Warning, DatabaseError, InternalError,146# of definition above and then reverse it.
145 OperationalError, ProgrammingError, IntegrityError,147_wrapped_exception_types = tuple(reversed((
146 DataError, NotSupportedError, InterfaceError):148 Error,
147 module_exception = getattr(module, exception.__name__, None)149 Warning,
148 if (module_exception is not None and150 InterfaceError,
149 isinstance(module_exception, (type, types.ClassType))):151 DatabaseError,
150 # XXX This may need to be revisited when porting to Python 3 if152 InternalError,
151 # virtual subclasses are still ignored for exception handling153 OperationalError,
152 # (https://bugs.python.org/issue12029).154 ProgrammingError,
153 exception.register(module_exception)155 IntegrityError,
156 DataError,
157 NotSupportedError,
158 )))
159
160
161@contextmanager
162def wrap_exceptions(database):
163 """Context manager that re-raises DB exceptions as StormError instances."""
164 try:
165 yield
166 except Exception as e:
167 module = database._exception_module
168 if module is None:
169 # This backend does not support re-raising database exceptions.
170 raise
171 for wrapper_type in _wrapped_exception_types:
172 dbapi_type = getattr(module, wrapper_type.__name__, None)
173 if (dbapi_type is not None and
174 isinstance(dbapi_type, six.class_types) and
175 isinstance(e, dbapi_type)):
176 wrapped = database._wrap_exception(wrapper_type, e)
177 tb = sys.exc_info()[2]
178 # As close to "raise wrapped.with_traceback(tb) from e" as
179 # we can manage, but without causing syntax errors on
180 # various versions of Python.
181 if six.PY2:
182 six.reraise(wrapped, None, tb)
183 else:
184 six.raise_from(wrapped.with_traceback(tb), e)
185 raise
154186
=== modified file 'tests/database.py'
--- tests/database.py 2019-06-05 11:41:07 +0000
+++ tests/database.py 2019-07-02 15:55:56 +0000
@@ -96,6 +96,9 @@
9696
97class FakeConnection(object):97class FakeConnection(object):
9898
99 def __init__(self):
100 self._database = Database()
101
99 def _check_disconnect(self, _function, *args, **kwargs):102 def _check_disconnect(self, _function, *args, **kwargs):
100 return _function(*args, **kwargs)103 return _function(*args, **kwargs)
101104

Subscribers

People subscribed via source and target branches

to status/vote changes: