From c620f18be14b994337a3bb939974e1fb769823fd Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Sat, 19 Feb 2011 00:05:43 +0000 Subject: [PATCH] Provide cursor.description as named tuple if possible If namedtuple() is not available, use regular tuples. --- NEWS | 1 + doc/src/cursor.rst | 13 ++++++++---- psycopg/pqpath.c | 24 +++++++++++++++++++--- psycopg/psycopgmodule.c | 42 ++++++++++++++++++++++++++++++++++++++ tests/extras_dictcursor.py | 28 ++++++++----------------- tests/test_cursor.py | 38 +++++++++++++++++++++++++++++++++- tests/testutils.py | 13 ++++++++++++ 7 files changed, 131 insertions(+), 28 deletions(-) diff --git a/NEWS b/NEWS index abf4aab8..133c3e6e 100644 --- a/NEWS +++ b/NEWS @@ -11,6 +11,7 @@ What's new in psycopg 2.4 into Python tuples/namedtuples. - More efficient iteration on named cursors, fetching 'itersize' records at time from the backend. + - 'cursor.description' is provided in named tuples if available. - Connections and cursors are weakly referenceable. - Added 'b' and 't' mode to large objects: write can deal with both bytes strings and unicode; read can return either bytes strings or decoded diff --git a/doc/src/cursor.rst b/doc/src/cursor.rst index 467ff794..0d1fdc67 100644 --- a/doc/src/cursor.rst +++ b/doc/src/cursor.rst @@ -39,8 +39,9 @@ The ``cursor`` class This read-only attribute is a sequence of 7-item sequences. - Each of these sequences contains information describing one result - column: + Each of these sequences is a named tuple (a regular tuple if + `!collections.namedtuple()` is not available) containing information + describing one result column: 0. `!name`: the name of the column returned. 1. `!type_code`: the PostgreSQL OID of the column. You can use the @@ -53,11 +54,11 @@ The ``cursor`` class always `!None` unless the :envvar:`PSYCOPG_DISPLAY_SIZE` parameter is set at compile time. See also PQgetlength_. 3. `!internal_size`: the size in bytes of the column associated to - this column on the server. Set to a egative value for + this column on the server. Set to a negative value for variable-size types See also PQfsize_. 4. `!precision`: total number of significant digits in columns of type |NUMERIC|_. `!None` for other types. - 5. `!scale`: count of decimal digits in the freactional part in + 5. `!scale`: count of decimal digits in the fractional part in columns of type |NUMERIC|. `!None` for other types. 6. `!null_ok`: always `!None` as not easy to retrieve from the libpq. @@ -72,6 +73,10 @@ The ``cursor`` class .. _NUMERIC: http://www.postgresql.org/docs/9.0/static/datatype-numeric.html#DATATYPE-NUMERIC-DECIMAL .. |NUMERIC| replace:: :sql:`NUMERIC` + .. versionchanged:: 2.4 + if possible, columns descriptions are named tuple instead of + regular tuples. + .. method:: close() Close the cursor now (rather than whenever `!__del__()` is diff --git a/psycopg/pqpath.c b/psycopg/pqpath.c index 7f9a1013..f61549f2 100644 --- a/psycopg/pqpath.c +++ b/psycopg/pqpath.c @@ -42,6 +42,9 @@ #include +extern HIDDEN PyObject *psyco_DescriptionType; + + /* Strip off the severity from a Postgres error message. */ static const char * strip_severity(const char *msg) @@ -948,7 +951,6 @@ _pq_fetch_tuples(cursorObject *curs) Py_BLOCK_THREADS; dtitem = PyTuple_New(7); - PyTuple_SET_ITEM(curs->description, i, dtitem); /* fill the right cast function by accessing three different dictionaries: - the per-cursor dictionary, if available (can be NULL or None) @@ -1021,8 +1023,24 @@ _pq_fetch_tuples(cursorObject *curs) /* 6/ FIXME: null_ok??? */ Py_INCREF(Py_None); PyTuple_SET_ITEM(dtitem, 6, Py_None); - - Py_UNBLOCK_THREADS; + + /* Convert into a namedtuple if available */ + if (Py_None != psyco_DescriptionType) { + PyObject *tmp = dtitem; + if ((dtitem = PyObject_CallObject(psyco_DescriptionType, tmp))) { + Py_DECREF(tmp); + } + else { + /* FIXME: this function is painfully missing any error check. + * The caller doesn't expect them, so swallow it. */ + PyErr_Clear(); + dtitem = tmp; + } + } + + PyTuple_SET_ITEM(curs->description, i, dtitem); + + Py_UNBLOCK_THREADS; } if (dsize) { diff --git a/psycopg/psycopgmodule.c b/psycopg/psycopgmodule.c index 59562e25..82a6a2d1 100644 --- a/psycopg/psycopgmodule.c +++ b/psycopg/psycopgmodule.c @@ -69,6 +69,9 @@ HIDDEN int psycopg_debug_enabled = 0; /* Python representation of SQL NULL */ HIDDEN PyObject *psyco_null = NULL; +/* The type of the cursor.description items */ +HIDDEN PyObject *psyco_DescriptionType = NULL; + /** connect module-level function **/ #define psyco_connect_doc \ "connect(dsn, ...) -- Create a new database connection.\n\n" \ @@ -685,6 +688,44 @@ psyco_GetDecimalType(void) } +/* Create a namedtuple for cursor.description items + * + * Return None in case of expected errors (e.g. namedtuples not available) + * NULL in case of errors to propagate. + */ +static PyObject * +psyco_make_description_type(void) +{ + PyObject *nt = NULL; + PyObject *coll = NULL; + PyObject *rv = NULL; + + /* Try to import collections.namedtuple */ + if (!(coll = PyImport_ImportModule("collections"))) { + Dprintf("psyco_make_description_type: collections import failed"); + PyErr_Clear(); + rv = Py_None; + goto exit; + } + if (!(nt = PyObject_GetAttrString(coll, "namedtuple"))) { + Dprintf("psyco_make_description_type: no collections.namedtuple"); + PyErr_Clear(); + rv = Py_None; + goto exit; + } + + /* Build the namedtuple */ + rv = PyObject_CallFunction(nt, "ss", "Column", + "name type_code display_size internal_size precision scale null_ok"); + +exit: + Py_XDECREF(coll); + Py_XDECREF(nt); + + return rv; +} + + /** method table and module initialization **/ static PyMethodDef psycopgMethods[] = { @@ -886,6 +927,7 @@ INIT_MODULE(_psycopg)(void) psycoEncodings = PyDict_New(); psyco_encodings_fill(psycoEncodings); psyco_null = Bytes_FromString("NULL"); + psyco_DescriptionType = psyco_make_description_type(); /* set some module's parameters */ PyModule_AddStringConstant(module, "__version__", PSYCOPG_VERSION); diff --git a/tests/extras_dictcursor.py b/tests/extras_dictcursor.py index 1bb44ad4..70f51d23 100755 --- a/tests/extras_dictcursor.py +++ b/tests/extras_dictcursor.py @@ -16,7 +16,7 @@ import psycopg2 import psycopg2.extras -from testutils import unittest +from testutils import unittest, skip_if_no_namedtuple from testconfig import dsn @@ -112,18 +112,6 @@ class ExtrasDictCursorTests(unittest.TestCase): self.failUnless(row[0] == 'qux') -def if_has_namedtuple(f): - def if_has_namedtuple_(self): - try: - from collections import namedtuple - except ImportError: - return self.skipTest("collections.namedtuple not available") - else: - return f(self) - - if_has_namedtuple_.__name__ = f.__name__ - return if_has_namedtuple_ - class NamedTupleCursorTest(unittest.TestCase): def setUp(self): from psycopg2.extras import NamedTupleConnection @@ -147,7 +135,7 @@ class NamedTupleCursorTest(unittest.TestCase): if self.conn is not None: self.conn.close() - @if_has_namedtuple + @skip_if_no_namedtuple def test_fetchone(self): curs = self.conn.cursor() curs.execute("select * from nttest where i = 1") @@ -157,7 +145,7 @@ class NamedTupleCursorTest(unittest.TestCase): self.assertEqual(t[1], 'foo') self.assertEqual(t.s, 'foo') - @if_has_namedtuple + @skip_if_no_namedtuple def test_fetchmany(self): curs = self.conn.cursor() curs.execute("select * from nttest order by 1") @@ -168,7 +156,7 @@ class NamedTupleCursorTest(unittest.TestCase): self.assertEqual(res[1].i, 2) self.assertEqual(res[1].s, 'bar') - @if_has_namedtuple + @skip_if_no_namedtuple def test_fetchall(self): curs = self.conn.cursor() curs.execute("select * from nttest order by 1") @@ -181,7 +169,7 @@ class NamedTupleCursorTest(unittest.TestCase): self.assertEqual(res[2].i, 3) self.assertEqual(res[2].s, 'baz') - @if_has_namedtuple + @skip_if_no_namedtuple def test_iter(self): curs = self.conn.cursor() curs.execute("select * from nttest order by 1") @@ -219,7 +207,7 @@ class NamedTupleCursorTest(unittest.TestCase): # skip the test pass - @if_has_namedtuple + @skip_if_no_namedtuple def test_record_updated(self): curs = self.conn.cursor() curs.execute("select 1 as foo;") @@ -231,7 +219,7 @@ class NamedTupleCursorTest(unittest.TestCase): self.assertEqual(r.bar, 2) self.assertRaises(AttributeError, getattr, r, 'foo') - @if_has_namedtuple + @skip_if_no_namedtuple def test_no_result_no_surprise(self): curs = self.conn.cursor() curs.execute("update nttest set s = s") @@ -240,7 +228,7 @@ class NamedTupleCursorTest(unittest.TestCase): curs.execute("update nttest set s = s") self.assertRaises(psycopg2.ProgrammingError, curs.fetchall) - @if_has_namedtuple + @skip_if_no_namedtuple def test_minimal_generation(self): # Instrument the class to verify it gets called the minimum number of times. from psycopg2.extras import NamedTupleCursor diff --git a/tests/test_cursor.py b/tests/test_cursor.py index b8a8b662..74f86a4d 100755 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -27,7 +27,7 @@ import psycopg2 import psycopg2.extensions from psycopg2.extensions import b from testconfig import dsn -from testutils import unittest, skip_before_postgres +from testutils import unittest, skip_before_postgres, skip_if_no_namedtuple class CursorTests(unittest.TestCase): @@ -172,6 +172,42 @@ class CursorTests(unittest.TestCase): # everything swallowed in two gulps self.assertEqual(rv, [(i,((i - 1) % 30) + 1) for i in range(1,51)]) + @skip_if_no_namedtuple + def test_namedtuple_description(self): + curs = self.conn.cursor() + curs.execute("""select + 3.14::decimal(10,2) as pi, + 'hello'::text as hi, + '2010-02-18'::date as now; + """) + self.assertEqual(len(curs.description), 3) + for c in curs.description: + self.assertEqual(len(c), 7) # DBAPI happy + for a in ('name', 'type_code', 'display_size', 'internal_size', + 'precision', 'scale', 'null_ok'): + self.assert_(hasattr(c, a), a) + + c = curs.description[0] + self.assertEqual(c.name, 'pi') + self.assert_(c.type_code in psycopg2.extensions.DECIMAL.values) + self.assert_(c.internal_size > 0) + self.assertEqual(c.precision, 10) + self.assertEqual(c.scale, 2) + + c = curs.description[1] + self.assertEqual(c.name, 'hi') + self.assert_(c.type_code in psycopg2.STRING.values) + self.assert_(c.internal_size < 0) + self.assertEqual(c.precision, None) + self.assertEqual(c.scale, None) + + c = curs.description[2] + self.assertEqual(c.name, 'now') + self.assert_(c.type_code in psycopg2.extensions.DATE.values) + self.assert_(c.internal_size > 0) + self.assertEqual(c.precision, None) + self.assertEqual(c.scale, None) + def test_suite(): return unittest.TestLoader().loadTestsFromName(__name__) diff --git a/tests/testutils.py b/tests/testutils.py index 8e99f044..26551d4e 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -127,6 +127,19 @@ def skip_if_tpc_disabled(f): return skip_if_tpc_disabled_ +def skip_if_no_namedtuple(f): + def skip_if_no_namedtuple_(self): + try: + from collections import namedtuple + except ImportError: + return self.skipTest("collections.namedtuple not available") + else: + return f(self) + + skip_if_no_namedtuple_.__name__ = f.__name__ + return skip_if_no_namedtuple_ + + def skip_if_no_iobase(f): """Skip a test if io.TextIOBase is not available.""" def skip_if_no_iobase_(self):