Skip to content

gh-114576: Add command-line interface for dbm module #137893

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions Doc/library/dbm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,70 @@
that this factor changes for each :mod:`dbm` submodule.

.. versionadded:: next


.. _dbm-commandline:
.. program:: dbm
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.. program:: directive should be placed closer to options

Suggested change
.. program:: dbm


Command-line interface
----------------------

.. module:: dbm.__main__
:synopsis: A command-line interface for DBM database operations.

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

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

The :mod:`dbm` module can be invoked as a script via ``python -m dbm``
to identify, examine, and reorganize DBM database files.

Command-line options
^^^^^^^^^^^^^^^^^^^^

.. option:: --whichdb file [file ...]
Comment on lines +524 to +525
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above

Suggested change
.. option:: --whichdb file [file ...]
.. program:: dbm
.. option:: --whichdb file [file ...]


Identify the database type for one or more database files:

.. code-block:: shell-session

$ python -m dbm --whichdb *.db
dbm.gnu - database1.db
dbm.sqlite3 - database2.db
UNKNOWN - corrupted.db

This command uses the :func:`whichdb` function to determine the type

Check warning on line 536 in Doc/library/dbm.rst

View workflow job for this annotation

GitHub Actions / Docs / Docs

py:func reference target not found: whichdb [ref.func]
of each database file. Files that cannot be identified are marked as
``UNKNOWN``.

.. option:: --dump file

Display the contents of a database file:

.. code-block:: shell-session

$ python -m dbm --dump mydb.db
username: john_doe
email: john@example.com
last_login: 2024-01-15

Keys and values are displayed in ``key: value`` format. Binary data
is decoded using UTF-8 with error replacement for display purposes.

.. option:: --reorganize file

Reorganize and compact a database file to reduce disk space:

.. code-block:: shell-session

$ python -m dbm --reorganize mydb.db
Reorganized database 'mydb.db'

This operation uses the database's native :meth:`!reorganize` method
when available (:mod:`dbm.sqlite3`, :mod:`dbm.gnu`, :mod:`dbm.dumb`).
For database types that don't support reorganization, an error message
is displayed.

.. option:: -h, --help

Show the help message.
Comment on lines +567 to +570
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should add help flag as option because it's not a common practice.

Suggested change
.. option:: -h, --help
Show the help message.

6 changes: 0 additions & 6 deletions Lib/dbm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
import io
import os
import struct
import sys


class error(Exception):
Expand Down Expand Up @@ -187,8 +186,3 @@ def whichdb(filename):

# Unknown
return ""


if __name__ == "__main__":
for filename in sys.argv[1:]:
print(whichdb(filename) or "UNKNOWN", filename)
83 changes: 83 additions & 0 deletions Lib/dbm/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import argparse
import os
import sys

from . import open as dbm_open, whichdb, error


def _whichdb_command(filenames):
exit_code = 0

for filename in filenames:
if os.path.exists(filename):
db_type = whichdb(filename)
print(f"{db_type or 'UNKNOWN'} {filename}")
else:
print(f"Error: File '{filename}' not found", file=sys.stderr)
exit_code = 1

return exit_code


def _dump_command(filename):
try:
with dbm_open(filename, "r") as db:
for key in db:
print(f"{key!r}: {db[key]!r}")
return 0
except error:
print(f"Error: Database '{filename}' not found", file=sys.stderr)
return 1


def _reorganize_command(filename):
try:
with dbm_open(filename, "c") as db:
if db.hasattr("reorganize"):
db.reorganize()
print(f"Reorganized database: '{filename}'", file=sys.stderr)
else:
print("Database type doesn't support reorganize method",
file=sys.stderr)
return 1
return 0
except error:
print(f"Error: Database '{filename}' not found or cannot be opened",
file=sys.stderr)
return 1


def main():
parser = argparse.ArgumentParser(
prog="python -m dbm", description="DBM toolkit"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--whichdb",
Comment on lines +54 to +56
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of options, why not use subparsers for subcommands?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose not to include it to maintain clarity and simplicity, but I’m happy to add it if needed.

nargs="+",
metavar="file",
help="Identify database type for one or more files",
)
group.add_argument(
"--dump", metavar="file", help="Display database contents"
)
group.add_argument(
"--reorganize",
metavar="file",
help="Reorganize the database",
)
options = parser.parse_args()

try:
if options.whichdb:
return _whichdb_command(options.whichdb)
elif options.dump:
return _dump_command(options.dump)
elif options.reorganize:
return _reorganize_command(options.reorganize)
except KeyboardInterrupt:
return 1


if __name__ == "__main__":
sys.exit(main())
92 changes: 90 additions & 2 deletions Lib/test/test_dbm.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Test script for the dbm.open function based on testdumbdbm.py"""

import unittest
import dbm
import os

from test.support import import_helper
from test.support import os_helper
from test.support.script_helper import assert_python_ok, assert_python_failure


try:
Expand Down Expand Up @@ -309,6 +309,94 @@ def setUp(self):
self.dbm = import_helper.import_fresh_module('dbm')


class DBMCommandLineTestCase(unittest.TestCase):

def setUp(self):
self.addCleanup(cleaunup_test_dir)
setup_test_dir()
self.test_db = os.path.join(dirname, 'test.db')
with dbm.open(self.test_db, 'c') as db:
db[b'key1'] = b'value1'
db[b'key2'] = b'value2'
self.empty_db = os.path.join(dirname, 'empty.db')
with dbm.open(self.empty_db, 'c'):
pass
self.dbm = import_helper.import_fresh_module('dbm')

def run_cmd_ok(self, *args):
return assert_python_ok('-m', 'dbm', *args).out

def run_cmd_error(self, *args):
return assert_python_failure('-m', 'dbm', *args)

def test_help(self):
output = self.run_cmd_ok('-h')
self.assertIn(b'usage:', output)
self.assertIn(b'python -m dbm', output)
self.assertIn(b'--help', output)
self.assertIn(b'whichdb', output)
self.assertIn(b'dump', output)
self.assertIn(b'reorganize', output)

def test_whichdb_command(self):
output = self.run_cmd_ok('--whichdb', self.test_db)
self.assertIn(self.test_db.encode(), output)
output = self.run_cmd_ok('--whichdb', self.test_db, self.empty_db)
self.assertIn(self.test_db.encode(), output)
self.assertIn(self.empty_db.encode(), output)

def test_whichdb_nonexistent_file(self):
rc, _, stderr = self.run_cmd_error('--whichdb', "nonexistent_db")
self.assertEqual(rc, 1)
self.assertIn(b'not found', stderr)

def test_whichdb_unknown_format(self):
text_file = os.path.join(dirname, 'text.txt')
with open(text_file, 'w') as f:
f.write('This is not a database file')
output = self.run_cmd_ok('--whichdb', text_file)
self.assertIn(b'UNKNOWN', output)
self.assertIn(text_file.encode(), output)

def test_whichdb_output_format(self):
output = self.run_cmd_ok('--whichdb', self.test_db).decode()
self.assertIn(self.test_db, output)

def test_dump_command(self):
output = self.run_cmd_ok('--dump', self.test_db)
self.assertIn(b"b'key1': b'value1'", output)
self.assertIn(b"b'key2': b'value2'", output)

def test_dump_empty_database(self):
output = self.run_cmd_ok('--dump', self.empty_db)
self.assertEqual(output.strip(), b'')

def test_dump_nonexistent_database(self):
rc, _, stderr = self.run_cmd_error('--dump', "nonexistent_db")
self.assertEqual(rc, 1)
self.assertIn(b'not found', stderr)

def test_reorganize_command(self):
self.addCleanup(setattr, dbm, '_defaultmod', dbm._defaultmod)
for module in dbm_iterator():
setup_test_dir()
dbm._defaultmod = module
with module.open(_fname, 'c') as f:
f[b"1"] = b"1"
if hasattr(module, 'reorganize'):
with module.open(_fname, 'c') as db:
output = self.run_cmd_ok('--reorganize', db)
self.assertIn(b'Reorganized', output)

def test_output_format_consistency(self):
output = self.run_cmd_ok('--dump', self.test_db)
lines = output.decode().strip().split('\n')
for line in lines:
if line.strip(): # Skip empty lines
self.assertIn(':', line)
parts = line.split(':', 1)
self.assertEqual(len(parts), 2)

for mod in dbm_iterator():
assert mod.__name__.startswith('dbm.')
suffix = mod.__name__[4:]
Expand Down
Loading