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 all commits
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
test 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
53 changes: 49 additions & 4 deletions Doc/library/dbm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@

--------------

:mod:`dbm` is a generic interface to variants of the DBM database ---
:mod:`dbm.gnu` or :mod:`dbm.ndbm`. If none of these modules is installed, the
:mod:`dbm` is a generic interface to variants of the DBM database:

* :mod:`dbm.sqlite3`
* :mod:`dbm.gnu`
* :mod:`dbm.ndbm`

If none of these modules are installed, the
slow-but-simple implementation in module :mod:`dbm.dumb` will be used. There
is a `third party interface <https://www.jcea.es/programacion/pybsddb.htm>`_ to
the Oracle Berkeley DB.
Expand All @@ -25,8 +30,8 @@ the Oracle Berkeley DB.
.. function:: whichdb(filename)

This function attempts to guess which of the several simple database modules
available --- :mod:`dbm.gnu`, :mod:`dbm.ndbm` or :mod:`dbm.dumb` --- should
be used to open a given file.
available --- :mod:`dbm.sqlite3`, :mod:`dbm.gnu`, :mod:`dbm.ndbm`,
or :mod:`dbm.dumb` --- should be used to open a given file.

Return one of the following values:

Expand Down Expand Up @@ -144,6 +149,46 @@ then prints out the contents of the database::

The individual submodules are described in the following sections.

:mod:`dbm.sqlite3` --- SQLite backend for dbm
---------------------------------------------

.. module:: dbm.sqlite3
:platform: All
:synopsis: SQLite backend for dbm

.. versionadded:: 3.13

**Source code:** :source:`Lib/dbm/sqlite3.py`

--------------

This module uses the standard library :mod:`sqlite3` module to provide an
SQLite backend for the :mod:`dbm` module.
The files created by :mod:`dbm.sqlite3` can thus be opened by :mod:`sqlite3`,
or any other SQLite browser, including the SQLite CLI.

.. function:: open(filename, /, flag="r", mode=0o666)

Open an SQLite database.
The returned object behaves like a :term:`mapping`,
implements a :meth:`!close` method,
and supports a "closing" context manager via the :keyword:`with` keyword.

:param filename:
The path to the database to be opened.
:type filename: :term:`path-like object`

:param str flag:

* ``'r'`` (default): |flag_r|
* ``'w'``: |flag_w|
* ``'c'``: |flag_c|
* ``'n'``: |flag_n|

:param mode:
The Unix file access mode of the file (default: octal ``0o666``),
used only when the database has to be created.


:mod:`dbm.gnu` --- GNU database manager
---------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ dis
the ``show_offsets`` parameter.
(Contributed by Irit Katriel in :gh:`112137`.)

dbm
---

* Add :meth:`dbm.gnu.gdbm.clear` and :meth:`dbm.ndbm.ndbm.clear` methods that remove all items
from the database.
(Contributed by Donghee Na in :gh:`107122`.)

* Add new :mod:`dbm.sqlite3` backend.
(Contributed by Raymond Hettinger and Erlend E. Aasland in :gh:`100414`.)

doctest
-------

Expand Down
8 changes: 6 additions & 2 deletions 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 database 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 Expand Up @@ -38,7 +38,7 @@
class error(Exception):
pass

_names = ['dbm.gnu', 'dbm.ndbm', 'dbm.dumb']
_names = ['dbm.gnu', 'dbm.ndbm', 'dbm.sqlite3', 'dbm.dumb']
_defaultmod = None
_modules = {}

Expand Down Expand Up @@ -164,6 +164,10 @@ def whichdb(filename):
if len(s) != 4:
return ""

# Check for SQLite3 header string.
if s16 == b"SQLite format 3\0":
return "dbm.sqlite3"

# Convert to 4-byte int in native byte order -- return "" if impossible
try:
(magic,) = struct.unpack("=l", s)
Expand Down
141 changes: 141 additions & 0 deletions Lib/dbm/sqlite3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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 BLOB UNIQUE NOT NULL,
value BLOB NOT NULL
)
"""
GET_SIZE = "SELECT COUNT (key) FROM Dict"
LOOKUP_KEY = "SELECT value FROM Dict WHERE key = CAST(? AS BLOB)"
STORE_KV = "REPLACE INTO Dict (key, value) VALUES (CAST(? AS BLOB), CAST(? AS BLOB))"
DELETE_KEY = "DELETE FROM Dict WHERE key = CAST(? AS BLOB)"
ITER_KEYS = "SELECT key FROM Dict"


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)
uri = path.absolute().as_uri()
while "//" in uri:
uri = uri.replace("//", "/")
return uri


class _Database(MutableMapping):

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

path = os.fsdecode(path)
match flag:
case "r":
flag = "ro"
case "w":
flag = "rw"
case "c":
flag = "rwc"
Path(path).touch(mode=mode, exist_ok=True)
case "n":
flag = "rwc"
Path(path).unlink(missing_ok=True)
Path(path).touch(mode=mode)
case _:
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.
uri = _normalize_uri(path)
uri = f"{uri}?mode={flag}"

try:
self._cx = sqlite3.connect(uri, autocommit=True, uri=True)
except sqlite3.Error as exc:
raise error(str(exc))

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

if flag == "rwc":
self._execute(BUILD_TABLE)

def _execute(self, *args, **kwargs):
if not self._cx:
raise error(_ERR_CLOSED)
try:
return closing(self._cx.execute(*args, **kwargs))
except sqlite3.Error as exc:
raise error(str(exc))

def __len__(self):
with self._execute(GET_SIZE) as cu:
row = cu.fetchone()
return row[0]

def __getitem__(self, key):
with self._execute(LOOKUP_KEY, (key,)) as cu:
row = cu.fetchone()
if not row:
raise KeyError(key)
return row[0]

def __setitem__(self, key, value):
self._execute(STORE_KV, (key, value))

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

def __iter__(self):
try:
with 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

def keys(self):
return list(super().keys())

def __enter__(self):
return self

def __exit__(self, *args):
self.close()


def open(filename, /, flag="r", mode=0o666):
"""Open a dbm.sqlite3 database and return the dbm object.

The 'filename' parameter is the name of the database file.

The optional 'flag' parameter can be one of ...:
'r' (default): open an existing database for read only access
'w': open an existing database for read/write access
'c': create a database if it does not exist; open for read/write access
'n': always create a new, empty database; open for read/write access

The optional 'mode' parameter is the Unix file access mode of the database;
only used when creating a new database. Default: 0o666.
"""
return _Database(filename, flag=flag, mode=mode)
28 changes: 28 additions & 0 deletions Lib/test/test_dbm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
from test.support import import_helper
from test.support import os_helper


try:
from dbm import sqlite3 as dbm_sqlite3
except ImportError:
dbm_sqlite3 = None


try:
from dbm import ndbm
except ImportError:
Expand Down Expand Up @@ -213,6 +220,27 @@ def test_whichdb_ndbm(self):
for path in fnames:
self.assertIsNone(self.dbm.whichdb(path))

@unittest.skipUnless(dbm_sqlite3, reason='Test requires dbm.sqlite3')
def test_whichdb_sqlite3(self):
# Databases created by dbm.sqlite3 are detected correctly.
with dbm_sqlite3.open(_fname, "c") as db:
db["key"] = "value"
self.assertEqual(self.dbm.whichdb(_fname), "dbm.sqlite3")

@unittest.skipUnless(dbm_sqlite3, reason='Test requires dbm.sqlite3')
def test_whichdb_sqlite3_existing_db(self):
# Existing sqlite3 databases are detected correctly.
sqlite3 = import_helper.import_module("sqlite3")
try:
# Create an empty database.
with sqlite3.connect(_fname) as cx:
cx.execute("CREATE TABLE dummy(database)")
cx.commit()
finally:
cx.close()
self.assertEqual(self.dbm.whichdb(_fname), "dbm.sqlite3")


def setUp(self):
self.addCleanup(cleaunup_test_dir)
setup_test_dir()
Expand Down
Loading