8000 gh-100414: Add SQLite backend to dbm by erlend-aasland · Pull Request #114481 · python/cpython · GitHub
[go: up one dir, main page]

Skip to content

gh-100414: Add SQLite backend to dbm #114481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 48 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
8c19c81
gh-100414: Add SQLite backend to dbm
erlend-aasland Jan 22, 2024
7f32772
Preliminary docs
erlend-aasland Jan 23, 2024
3f8a119
Decode path a little bit earlier
erlend-aasland Jan 23, 2024
4f82c88
Merge branch 'main' into sqlite/dbm
erlend-aasland Jan 23, 2024
38a9b41
Substitutions are already in place
erlend-aasland Jan 23, 2024
db0f148
Use CSV-style table, as the rest of the .rst file does
erlend-aasland Jan 23, 2024
e50d1e9
dbm.sqlite3.error is a subclass of OSError
erlend-aasland Jan 23, 2024
0abd7dc
Don't raise DB API exceptions; test this
erlend-aasland Jan 23, 2024
98fba0f
Pull in main
erlend-aasland Jan 23, 2024
2b97160
Don't panic on double close(); add more tests
erlend-aasland Jan 23, 2024
917bfba
Corruption tests: check all basic operations for flags 'r', 'w', 'c'
erlend-aasland Jan 23, 2024
874fb81
Add read-only specific test
erlend-aasland Jan 23, 2024
b655473
Address review: always import sqlite3; let ImportError deal with miss…
erlend-aasland Jan 24, 2024
95eba93
Test whichdb
erlend-aasland Jan 24, 2024
54751ba
tes 8000 t namespacing
erlend-aasland Jan 24, 2024
906748e
Test that 'c' works even if you already have a database; make sure BU…
erlend-aasland Jan 24, 2024
7320f18
Pull in main
erlend-aasland Jan 24, 2024
c89c5fb
Remove unneeded comma
erlend-aasland Jan 24, 2024
34d8c7f
Close cursors explicitly for each query
erlend-aasland Jan 24, 2024
608c229
Address review from Donghee, Serhiy, and myself:
erlend-aasland Jan 24, 2024
3d53054
Pull in main
erlend-aasland Jan 24, 2024
75f7c6a
Add URI tests, fix whitespace, catch errors during db creation
erlend-aasland Jan 24, 2024
3ac010c
Enable test on Windows
erlend-aasland Jan 24, 2024
08c3848
Use as_uri() instead
erlend-aasland Jan 24, 2024
775ff91
Skip tests with relative paths for now
erlend-aasland Jan 25, 2024
4ba9607
Add prelim docstring for dbm.sqlite3.open
erlend-aasland Jan 25, 2024
dc0ba26
Amend Windows URI expected results
erlend-aasland Jan 25, 2024
37f99ab
For now, just remove the relative URI tests on Windows
erlend-aasland Jan 25, 2024
177b200
Document dbm.sqlite3.open signature
erlend-aasland Jan 25, 2024
1a661a5
Update Lib/test/test_dbm.py
erlend-aasland Jan 25, 2024
fae8603
Update Lib/test/test_dbm.py
erlend-aasland Jan 25, 2024
d5fc39c
Only test URI substitution and normalisation; discard prefix
erlend-aasland Jan 25, 2024
d350ce2
Pull in changes from PR
erlend-aasland Jan 25, 2024
a8db17f
Doc amendments
erlend-aasland Jan 25, 2024
1f87517
Pull in main
erlend-aasland Jan 26, 2024
898dd71
Pull in main
erlend-aasland Jan 26, 2024
bb49fab
Mark up flags as list; it renders better
erlend-aasland Jan 26, 2024
26a2c69
Apply suggestions from code review
erlend-aasland Jan 26, 2024
ae53f95
Address review: use closing() in _execute() wrapper
erlend-aasland Jan 26, 2024
926ef1a
Update Doc/library/dbm.rst
erlend-aasland Jan 26, 2024
c0878d9
Add support for 'mode' param in dbm.sqlite3.open()
erlend-aasland Jan 30, 2024
b7111ee
Pull in main
erlend-aasland Jan 30, 2024
48972a6
Pull in main
erlend-aasland Jan 31, 2024
d6d7c66
Fix test_misuse_reinit()
erlend-aasland Jan 31, 2024
bc849c3
Merge branch 'main' into sqlite/dbm
erlend-aasland Feb 14, 2024
e782fad
Address Serhiy's offline remark: coerce keys/values to bytes
erlend-aasland Feb 14, 2024
b1b9a9b
Align docs to e782fad38f
erlend-aasland Feb 14, 2024
34930cb
Compat with other backends: silently coerce keys to bytes
erlend-aasland Feb 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Address review from Donghee, Serhiy, and myself:
- Amend dbm docstring
- Add misuse guards
- Let SQLite manage the rowid
- Atomic delete
- Hide connection member
- Remove :memory: feature
- Improve URI normalisation
- Fix ValueError formatting
  • Loading branch information
erlend-aasland committed Jan 24, 2024
commit 608c22917dfffa04d2b212c4bc8c41d1451b1f0a
2 changes: 1 addition & 1 deletion Lib/dbm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import dbm
d = dbm.open(file, 'w', 0o666)

The returned object is a dbm.gnu, dbm.ndbm or dbm.dumb object, dependent on the
The returned object is a dbm.sqlite3, dbm.gnu, dbm.ndbm or dbm.dumb object, dependent on the
type of database being opened (determined by the whichdb function) in the case
of an existing dbm. If the dbm does not exist and the create or new flag ('c'
or 'n') was specified, the dbm type will be determined by the availability of
Expand Down
81 changes: 49 additions & 32 deletions Lib/dbm/sqlite3.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import os
import sqlite3
import sys
from pathlib import Path
from contextlib import suppress, closing
from collections.abc import MutableMapping

BUILD_TABLE = """
CREATE TABLE IF NOT EXISTS Dict (
key TEXT NOT NULL,
value BLOB NOT NULL,
PRIMARY KEY (key)
key TEXT UNIQUE NOT NULL,
value BLOB NOT NULL
)
"""
GET_SIZE = "SELECT COUNT (key) FROM Dict"
LOOKUP_KEY = "SELECT value FROM Dict WHERE key = ? LIMIT 1"
LOOKUP_KEY = "SELECT value FROM Dict WHERE key = ?"
STORE_KV = "REPLACE INTO Dict (key, value) VALUES (?, ?)"
DELETE_KEY = "DELETE FROM Dict WHERE key = ?"
ITER_KEYS = "SELECT key FROM Dict"
Expand All @@ -21,17 +22,29 @@ class error(OSError):
pass


_ERR_CLOSED = "DBM object has already been closed"
_ERR_REINIT = "DBM object does not support reinitialization"


def _normalize_uri_path(path):
path = path.replace("?", "%3f")
path = path.replace("#", "%23")
while "//" in path:
path = path.replace("//", "/")
return path
path = os.fsdecode(path)
for char in "%", "?", "#":
path = path.replace(char, f"%{ord(char):02x}")
path = Path(path)
if sys.platform == "win32":
if path.drive:
if not path.is_absolute():
path = Path(path).absolute()
return Path("/", path).as_posix()
return path.as_posix()


class _Database(MutableMapping):

def __init__(self, path, flag):
def __init__(self, path, /, flag):
if hasattr(self, "_cx"):
raise error(_ERR_REINIT)

match flag:
case "r":
flag = "ro"
Expand All @@ -46,26 +59,28 @@ def __init__(self, path, flag):
except FileNotFoundError:
pass
case _:
raise ValueError("Flag must be one of 'r', 'w', 'c', or 'n', not",
repr(flag))
raise ValueError("Flag must be one of 'r', 'w', 'c', or 'n', "
f"not {flag!r}")

# We use the URI format when opening the database.
path = os.fsdecode(path)
if not path or path == ":memory:":
uri = "file:?mode=memory"
else:
path = _normalize_uri_path(path)
uri = f"file:{path}?mode={flag}"
path = _normalize_uri_path(path)
uri = f"file:{path}?mode={flag}"

self._cx = sqlite3.connect(uri, autocommit=True, uri=True)

# This is an optimization only; it's ok if it fails.
with suppress(sqlite3.OperationalError):
self._cx.execute("PRAGMA journal_mode = wal")

self.cx = sqlite3.connect(uri, autocommit=True, uri=True)
self.cx.execute("PRAGMA journal_mode = wal")
if flag == "rwc":
with closing(self._execute(BUILD_TABLE)):
pass

def _execute(self, *args, **kwargs):
if not self._cx:
raise error(_ERR_CLOSED)
try:
ret = self.cx.execute(*args, **kwargs)
ret = self._cx.execute(*args, **kwargs)
except sqlite3.Error as exc:
raise error(str(exc))
else:
Expand All @@ -88,20 +103,22 @@ def __setitem__(self, key, value):
pass

def __delitem__(self, key):
if key not in self:
raise KeyError(key)
with closing(self._execute(DELETE_KEY, (key,))):
pass
with closing(self._execute(DELETE_KEY, (key,))) as cu:
if not cu.rowcount:
raise KeyError(key)

def __iter__(self):
with closing(self._execute(ITER_KEYS)) as cu:
for row in cu:
yield row[0]
try:
with closing(self._execute(ITER_KEYS)) as cu:
for row in cu:
yield row[0]
except sqlite3.Error as exc:
raise error(str(exc))

def close(self):
if self.cx:
self.cx.close()
self.cx = None
if self._cx:
self._cx.close()
self._cx = None

def keys(self):
return list(super().keys())
Expand All @@ -113,5 +130,5 @@ def __exit__(self, *args):
self.close()


def open(path, flag="r", mode=None):
def open(path, /, flag="r", mode=None):
return _Database(path, flag)
82 changes: 81 additions & 1 deletion Lib/test/test_dbm_sqlite3.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,52 @@ def test_readonly_iter(self):
self.assertEqual([k for k in self.db], ["key1", "key2"])


class ReadWrite(_SQLiteDbmTests):

def setUp(self):
super().setUp()
self.db = dbm_sqlite3.open(self.filename, "w")

def tearDown(self):
self.db.close()
super().tearDown()

def db_content(self):
with closing(sqlite3.connect(self.filename)) as cx:
keys = [r[0] for r in cx.execute("SELECT key FROM Dict")]
vals = [r[0] for r in cx.execute("SELECT value FROM Dict")]
return keys, vals

def test_readwrite_unique_key(self):
self.db["key"] = "value"
self.db["key"] = "other"
keys, vals = self.db_content()
self.assertEqual(keys, ["key"])
self.assertEqual(vals, ["other"])

def test_readwrite_delete(self):
self.db["key"] = "value"
self.db["new"] = "other"

del self.db["new"]
keys, vals = self.db_content()
self.assertEqual(keys, ["key"])
self.assertEqual(vals, ["value"])

del self.db["key"]
keys, vals = self.db_content()
self.assertEqual(keys, [])
self.assertEqual(vals, [])

def test_readwrite_null_key(self):
with self.assertRaises(dbm_sqlite3.error):
self.db[None] = "value"

def test_readwrite_null_value(self):
with self.assertRaises(dbm_sqlite3.error):
self.db["key"] = None


class Misuse(_SQLiteDbmTests):

def setUp(self):
Expand All @@ -72,7 +118,8 @@ def test_misuse_double_close(self):
self.db.close()

def test_misuse_invalid_flag(self):
with self.assertRaises(ValueError):
regex = "must be.*'r'.*'w'.*'c'.*'n', not 'invalid'"
with self.assertRaisesRegex(ValueError, regex):
dbm_sqlite3.open(self.filename, flag="invalid")

def test_misuse_double_delete(self):
Expand All @@ -85,6 +132,39 @@ def test_misuse_invalid_key(self):
with self.assertRaises(KeyError):
self.db["key"]

def test_misuse_iter_close1(self):
self.db["1"] = 1
it = iter(self.db)
self.db.close()
with self.assertRaises(dbm_sqlite3.error):
next(it)

def test_misuse_iter_close2(self):
self.db["1"] = 1
self.db["2"] = 2
it = iter(self.db)
next(it)
self.db.close()
with self.assertRaises(dbm_sqlite3.error):
next(it)

def test_misuse_use_after_close(self):
self.db.close()
with self.assertRaises(dbm_sqlite3.error):
self.db["read"]
with self.assertRaises(dbm_sqlite3.error):
self.db["write"] = "value"
with self.assertRaises(dbm_sqlite3.error):
del self.db["del"]
with self.assertRaises(dbm_sqlite3.error):
len(self.db)
with self.assertRaises(dbm_sqlite3.error):
self.db.keys()

def test_misuse_reinit(self):
with self.assertRaises(dbm_sqlite3.error):
self.db.__init__(":memory:", flag="rw")


class DataTypes(_SQLiteDbmTests):

Expand Down
0