Skip to content

Commit 4f01ce6

Browse files
committed
fix(parse-colnames): parse colnames from [decltype] in py>=3.7
Respect detect_type to PARSE_COLNAMES before parsing [decltype] from column aliases
1 parent 93e268b commit 4f01ce6

File tree

4 files changed

+152
-32
lines changed

4 files changed

+152
-32
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
2+
// README at: https://github.com/devcontainers/templates/tree/main/src/python
3+
{
4+
"name": "Python 3.7",
5+
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6+
"image": "mcr.microsoft.com/devcontainers/python:3.7",
7+
"features": {
8+
"ghcr.io/warrenbuckley/codespace-features/sqlite:1": {}
9+
},
10+
11+
// Features to add to the dev container. More info: https://containers.dev/features.
12+
// "features": {},
13+
14+
// Use 'forwardPorts' to make a list of ports inside the container available locally.
15+
// "forwardPorts": [],
16+
17+
// Use 'postCreateCommand' to run commands after the container is created.
18+
// "postCreateCommand": "pip3 install --user -r requirements.txt",
19+
20+
// Configure tool-specific properties.
21+
"customizations": {
22+
"vscode": {
23+
"extensions": [
24+
"littlefoxteam.vscode-python-test-adapter",
25+
"jkillian.custom-local-formatters",
26+
"ms-python.vscode-pylance",
27+
"ms-python.python",
28+
"ms-python.debugpy",
29+
"ms-python.black-formatter",
30+
"ms-python.isort",
31+
"ms-toolsai.jupyter"
32+
]
33+
}
34+
}
35+
36+
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
37+
// "remoteUser": "root"
38+
}

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
build/
33
experiments/
44
sdk/
5-
venv/
5+
venv*/
66
main.dSYM/
77
.env
88
*.pyc

src/sqlitecloud/dbapi2.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#
66
import logging
77
import re
8+
import sys
89
from datetime import date, datetime
910
from typing import (
1011
Any,
@@ -370,11 +371,23 @@ def description(
370371
if not self._is_result_rowset():
371372
return None
372373

374+
# Since py3.7:
375+
# bpo-39652: The column name found in sqlite3.Cursor.description is
376+
# now truncated on the first ‘[’ only if the PARSE_COLNAMES option is set.
377+
# https://github.com/python/cpython/issues/83833
378+
parse_colname = (
379+
self.connection.detect_types & PARSE_COLNAMES
380+
) == PARSE_COLNAMES
381+
if sys.version_info < (3, 7):
382+
parse_colname = True
383+
373384
description = ()
374385
for i in range(self._resultset.ncols):
386+
colname = self._resultset.colname[i]
387+
375388
description += (
376389
(
377-
self._parse_colname(self._resultset.colname[i])[0],
390+
self._parse_colname(colname)[0] if parse_colname else colname,
378391
None,
379392
None,
380393
None,
@@ -550,28 +563,6 @@ def setinputsizes(self, sizes) -> None:
550563
def setoutputsize(self, size, column=None) -> None:
551564
pass
552565

553-
def _parse_colname(self, colname: str) -> Tuple[str, str]:
554-
"""
555-
Parse the column name to extract the column name and the
556-
declared type if present when it follows the syntax `colname [decltype]`.
557-
558-
Args:
559-
colname (str): The column name with optional declared type.
560-
Eg: "mycol [mytype]"
561-
562-
Returns:
563-
Tuple[str, str]: The column name and the declared type.
564-
Eg: ("mycol", "mytype")
565-
"""
566-
# search for `[mytype]` in `mycol [mytype]`
567-
pattern = r"\[(.*?)\]"
568-
569-
matches = re.findall(pattern, colname)
570-
if not matches or len(matches) == 0:
571-
return colname, None
572-
573-
return colname.replace(f"[{matches[0]}]", "").strip(), matches[0]
574-
575566
def _call_row_factory(self, row: Tuple) -> object:
576567
if self.row_factory is None:
577568
return row
@@ -634,6 +625,28 @@ def _convert_value(
634625

635626
return value
636627

628+
def _parse_colname(self, colname: str) -> Tuple[str, str]:
629+
"""
630+
Parse the column name to extract the column name and the
631+
declared type if present when it follows the syntax `colname [decltype]`.
632+
633+
Args:
634+
colname (str): The column name with optional declared type.
635+
Eg: "mycol [mytype]"
636+
637+
Returns:
638+
Tuple[str, str]: The column name and the declared type.
639+
Eg: ("mycol", "mytype")
640+
"""
641+
# search for `[mytype]` in `mycol [mytype]`
642+
pattern = r"\[(.*?)\]"
643+
644+
matches = re.findall(pattern, colname)
645+
if not matches or len(matches) == 0:
646+
return colname, None
647+
648+
return colname.replace(f"[{matches[0]}]", "").strip(), matches[0]
649+
637650
def _parse_colnames(self, value: Any, colname: str) -> Optional[Any]:
638651
"""Convert the value using the explicit type in the column name."""
639652
_, decltype = self._parse_colname(colname)

src/tests/integration/test_sqlite3_parity.py

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import random
22
import sqlite3
3+
import sys
34
import time
45
from datetime import date, datetime
56

@@ -226,17 +227,85 @@ def test_description(self, connection, request):
226227
"sqlite3_connection",
227228
],
228229
)
229-
def test_cursor_description_with_explicit_decltype(self, connection, request):
230+
@pytest.mark.parametrize(
231+
"value",
232+
[
233+
("'hello world'", "'hello world'"),
234+
('"hello" "world"', "world"),
235+
('"hello" "my world"', "my world"),
236+
],
237+
)
238+
def test_cursor_description_with_column_alias(self, connection, value, request):
230239
connection = request.getfixturevalue(connection)
231240

232-
cursor = connection.execute(
233-
'SELECT "hello world", "hello" as "my world [sphere]", "hello" "world", "hello" "my world"'
234-
)
241+
cursor = connection.execute(f"SELECT {value[0]}")
242+
243+
assert cursor.description[0][0] == value[1]
244+
245+
# Only for py3.6
246+
@pytest.mark.skipif(
247+
sys.version_info >= (3, 7), reason="Different behavior in py>=3.7"
248+
)
249+
@pytest.mark.parametrize(
250+
"connection",
251+
[
252+
"sqlitecloud_dbapi2_connection",
253+
"sqlite3_connection",
254+
],
255+
)
256+
@pytest.mark.parametrize(
257+
"value",
258+
[
259+
('"hello" as "world [sphere]"', "world"),
260+
('"hello" as "my world [sphere]"', "my world"),
261+
('"hello" "world [sphere]"', "world"),
262+
],
263+
)
264+
def test_cursor_description_with_explicit_decltype_regardless_detect_type(
265+
self, connection, value, request
266+
):
267+
"""In py3.6 the `[decltype]` in column name is always parsed regardless the PARSE_COLNAMES.
268+
See bpo-39652 https://github.com/python/cpython/issues/83833"""
269+
270+
connection = request.getfixturevalue(connection)
271+
272+
cursor = connection.execute(f"SELECT {value[0]}")
273+
274+
assert cursor.description[0][0] == value[1]
275+
276+
# Only for py>=3.7
277+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Different behavior in py3.6")
278+
@pytest.mark.parametrize(
279+
"connection, module",
280+
[
281+
(get_sqlitecloud_dbapi2_connection, sqlitecloud),
282+
(get_sqlite3_connection, sqlite3),
283+
],
284+
)
285+
@pytest.mark.parametrize(
286+
"value, expected, parse_colnames",
287+
[
288+
('"hello" as "world [sphere]"', "world", True),
289+
('"hello" as "my world [sphere]"', "my world", True),
290+
('"hello" "world [sphere]"', "world", True),
291+
('"hello" as "world [sphere]"', "world [sphere]", False),
292+
('"hello" as "my world [sphere]"', "my world [sphere]", False),
293+
('"hello" "world [sphere]"', "world [sphere]", False),
294+
],
295+
)
296+
def test_cursor_description_with_explicit_decltype(
297+
self, connection, module, value, expected, parse_colnames
298+
):
299+
"""Since py3.7 the parsed of `[decltype]` disabled when PARSE_COLNAMES.
300+
See bpo-39652 https://github.com/python/cpython/issues/83833"""
301+
if parse_colnames:
302+
connection = next(connection(module.PARSE_COLNAMES))
303+
else:
304+
connection = next(connection())
305+
306+
cursor = connection.execute(f"SELECT {value}")
235307

236-
assert cursor.description[0][0] == '"hello world"'
237-
assert cursor.description[1][0] == "my world"
238-
assert cursor.description[2][0] == "world"
239-
assert cursor.description[3][0] == "my world"
308+
assert cursor.description[0][0] == expected
240309

241310
def test_fetch_one(self, sqlitecloud_dbapi2_connection, sqlite3_connection):
242311
sqlitecloud_connection = sqlitecloud_dbapi2_connection

0 commit comments

Comments
 (0)