Skip to content

Improve error message when database directory is not writable#6294

Open
VedantMadane wants to merge 1 commit intobeetbox:masterfrom
VedantMadane:fix-db-permission-error
Open

Improve error message when database directory is not writable#6294
VedantMadane wants to merge 1 commit intobeetbox:masterfrom
VedantMadane:fix-db-permission-error

Conversation

@VedantMadane
Copy link
Copy Markdown

Adds better error handling for SQLite OperationalError when the database file or directory is not writable. When the error message contains 'readonly' or 'unable to open', provides a helpful message suggesting the user check directory permissions. Also fixes a typo in the error message ('cannot not' to 'could not'). Closes #1676

@VedantMadane VedantMadane requested a review from a team as a code owner January 16, 2026 08:52
@github-actions
Copy link
Copy Markdown

Thank you for the PR! The changelog has not been updated, so here is a friendly reminder to check if you need to add an entry.

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The special-case readonly/unable to open branch currently assumes a directory permissions issue; consider broadening the message to mention both file and directory permissions (or phrasing it more generically) since these errors can also arise from file-level ACLs or missing paths.
  • When constructing db_dir = os.path.dirname(dbpath), it may be safer to normalize the path (e.g., via os.path.abspath) and handle the case where dbpath has no directory component, so that the resulting message about the directory being writable is always meaningful.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The special-case `readonly`/`unable to open` branch currently assumes a directory permissions issue; consider broadening the message to mention both file and directory permissions (or phrasing it more generically) since these errors can also arise from file-level ACLs or missing paths.
- When constructing `db_dir = os.path.dirname(dbpath)`, it may be safer to normalize the path (e.g., via `os.path.abspath`) and handle the case where `dbpath` has no directory component, so that the resulting message about the directory being writable is always meaningful.

## Individual Comments

### Comment 1
<location> `beets/ui/__init__.py:1513-1518` </location>
<code_context>
+        # Check for permission-related errors and provide a helpful message
+        error_str = str(db_error).lower()
+        dbpath_display = util.displayable_path(dbpath)
+        if "readonly" in error_str or "unable to open" in error_str:
+            db_dir = os.path.dirname(dbpath)
+            raise UserError(
+                f"database file {dbpath_display} could not be opened due to a "
+                f"permissions error. Please check that the directory "
+                f"{util.displayable_path(db_dir)} is writable."
+            )
         raise UserError(
</code_context>

<issue_to_address>
**issue (bug_risk):** The permission-specific branch may misdiagnose non-permission errors as permission issues.

Relying on the generic substring `"unable to open"` and mapping it directly to a permissions error is brittle. This message can also arise from non-permission issues (missing directory, malformed path, unavailable filesystem). Please either narrow the pattern to permission-specific cases, include the original `db_error` in the message for context, or soften the wording so it doesn’t claim permissions are the only cause.
</issue_to_address>

### Comment 2
<location> `beets/ui/__init__.py:1512-1522` </location>
<code_context>
+        dbpath_display = util.displayable_path(dbpath)
+        if "readonly" in error_str or "unable to open" in error_str:
+            db_dir = os.path.dirname(dbpath)
+            raise UserError(
+                f"database file {dbpath_display} could not be opened due to a "
+                f"permissions error. Please check that the directory "
+                f"{util.displayable_path(db_dir)} is writable."
+            )
         raise UserError(
</code_context>

<issue_to_address>
**suggestion:** The permission-focused message drops the original SQLite error details, reducing debuggability.

In this branch, the original `db_error` isn’t surfaced, unlike in the generic error path. Please include it here as well (e.g., by appending `f" (original error: {db_error})"`) so callers retain the underlying SQLite details for troubleshooting.

```suggestion
        dbpath_display = util.displayable_path(dbpath)
        if "readonly" in error_str or "unable to open" in error_str:
            db_dir = os.path.dirname(dbpath)
            raise UserError(
                f"database file {dbpath_display} could not be opened due to a "
                f"permissions error. Please check that the directory "
                f"{util.displayable_path(db_dir)} is writable "
                f"(original error: {db_error})."
            )
        raise UserError(
            f"database file {dbpath_display} could not be opened: {db_error}"
        )
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread beets/ui/__init__.py Outdated
Comment thread beets/ui/__init__.py
@codecov
Copy link
Copy Markdown

codecov bot commented Jan 16, 2026

Codecov Report

❌ Patch coverage is 77.77778% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.18%. Comparing base (137c049) to head (5f4c0cd).
⚠️ Report is 2 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
beets/ui/__init__.py 75.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master    #6294   +/-   ##
=======================================
  Coverage   70.18%   70.18%           
=======================================
  Files         147      147           
  Lines       18680    18688    +8     
  Branches     3042     3044    +2     
=======================================
+ Hits        13111    13117    +6     
- Misses       4927     4928    +1     
- Partials      642      643    +1     
Files with missing lines Coverage Δ
beets/dbcore/db.py 94.15% <100.00%> (ø)
beets/ui/__init__.py 82.10% <75.00%> (-0.13%) ⬇️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread beets/ui/__init__.py Outdated
@VedantMadane
Copy link
Copy Markdown
Author

VedantMadane commented Jan 16, 2026

readonly errors only occur on write operations, not during database open. I've pushed a fix that removes the
readonly check and keeps only the unable to open check, which does occur here when the directory isn't writable and the database doesn't exist yet.

Also refined the error message to be more specific about the directory-not-writable-on-create scenario.

VedantMadane added a commit to VedantMadane/beets that referenced this pull request Jan 25, 2026
Resolved conflict in docs/changelog.rst by keeping both bug fix entries:
- Database permission error message improvement (PR beetbox#6294)
- ArtResizer OSError handling (from master)
@VedantMadane VedantMadane force-pushed the fix-db-permission-error branch from f1fa526 to 29b4ee3 Compare February 10, 2026 10:50
@snejus
Copy link
Copy Markdown
Member

snejus commented Feb 23, 2026

Maybe it'd be worth addressing this error message as part of this PR as well?

Really modify and move? (Yes/no/select) 

Traceback (most recent call last):
  File "/home/sarunas/.local/bin/beet", line 8, in <module>
    main()
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1661, in main
    _raw_main(args)
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1640, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 105, in modify_func
    modify_items(
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 63, in modify_items
    obj.try_sync(write, move, inherit)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1027, in try_sync
    self.move(with_album=with_album)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1166, in move
    self.store()
  File "/home/sarunas/repo/beets/beets/library/models.py", line 72, in store
    super().store(fields)
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 656, in store
    tx.mutate(
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 1016, in mutate
    cursor = self.db._connection().execute(statement, subvals)
sqlite3.OperationalError: database is locked

This happens whenever you try to write to the database while it is already being written.

You can re-produce it by opening two terminal windows:

  1. Run beet modify path::aa aaa=bb in the first one
  2. Run beet modify path::aa aaa=b in the second one
  3. Accept (press Enter) in the first one, and in the second one immediately right after
  4. See the second one fail with the error above.

@VedantMadane VedantMadane force-pushed the fix-db-permission-error branch from c63bbfe to 61b677c Compare March 1, 2026 18:06
@snejus
Copy link
Copy Markdown
Member

snejus commented Mar 3, 2026

@VedantMadane what do you think about this? Also note that CI is complaining about some lint issues.

Maybe it'd be worth addressing this error message as part of this PR as well?

Really modify and move? (Yes/no/select) 

Traceback (most recent call last):
  File "/home/sarunas/.local/bin/beet", line 8, in <module>
    main()
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1661, in main
    _raw_main(args)
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1640, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 105, in modify_func
    modify_items(
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 63, in modify_items
    obj.try_sync(write, move, inherit)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1027, in try_sync
    self.move(with_album=with_album)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1166, in move
    self.store()
  File "/home/sarunas/repo/beets/beets/library/models.py", line 72, in store
    super().store(fields)
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 656, in store
    tx.mutate(
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 1016, in mutate
    cursor = self.db._connection().execute(statement, subvals)
sqlite3.OperationalError: database is locked

This happens whenever you try to write to the database while it is already being written.

You can re-produce it by opening two terminal windows:

1. Run `beet modify path::aa aaa=bb` in the first one

2. Run `beet modify path::aa aaa=b` in the second one

3. Accept (press Enter) in the first one, and in the second one immediately right after

4. See the second one fail with the error above.

@snejus
Copy link
Copy Markdown
Member

snejus commented Mar 11, 2026

@VedantMadane I'm waiting for your response to my comments above.

@snejus snejus added the core Pull requests that modify the beets core `beets` label Apr 4, 2026
@snejus snejus marked this pull request as draft April 8, 2026 17:44
@snejus
Copy link
Copy Markdown
Member

snejus commented Apr 8, 2026

Please mark it ready once you responded to my comments above @VedantMadane.

@VedantMadane VedantMadane force-pushed the fix-db-permission-error branch from 5f4c0cd to a15df48 Compare April 12, 2026 09:12
@VedantMadane VedantMadane marked this pull request as ready for review April 12, 2026 09:13
Copilot AI review requested due to automatic review settings April 12, 2026 09:13
@VedantMadane
Copy link
Copy Markdown
Author

@snejus Sorry for the delayed response! Here's what I've done:

  1. Squashed the branch into a single clean commit rebased on current master. This cleans up the messy merge history.

  2. Moved the changelog entry to the Unreleased section (it was stuck in 2.9.0 from before the release, which caused the Check docs CI failure).

  3. Regarding the "database is locked" error: The busy timeout bump from 5s to 30s in this PR should significantly reduce the frequency of that error during concurrent access — SQLite will now retry for 30 seconds before giving up. For a dedicated user-facing error message for database is locked specifically (e.g., "another beets instance may be writing to the database"), I think that's better as a follow-up since it's a different code path (write-time vs. open-time). Happy to open that as a separate PR if you'd like.

Should be green now — please take another look when you get a chance.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

PR try make beets give nicer UserError when SQLite fail open DB because permissions (like directory not writable). It also add tests for new message.

Changes:

  • Add special-case handling in ui._open_library for SQLite “unable to open” errors to suggest checking file/dir write permissions.
  • Add UI init tests that assert the improved error message and keep fallback message for other OperationalError cases.
  • Change DB core default timeout and update changelog entry (plus some packaging metadata edits).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
beets/ui/__init__.py Add permission-hint UserError when SQLite open fail.
test/ui/test_ui_init.py Add tests for new database-open error message behavior.
beets/dbcore/db.py Adjust DB init defaults (timeout) and tweak migration base.
docs/changelog.rst Add unreleased note about improved DB open error message + timeout + typo fix.
pyproject.toml Modify project version / classifiers / dependency constraints, plus ruff ignore for new test.
Comments suppressed due to low confidence (2)

beets/dbcore/db.py:1122

  • grug see Database timeout default changed to 30s, but Library always passes timeout from beets.config['timeout'] (default in config_default.yaml still 5.0). so this change likely not actually increase default busy timeout for normal beets usage. either bump config default / docs, or wire new default into config path.

    def __init__(self, path, timeout: float = 30.0):
        if sqlite3.threadsafety == 0:
            raise RuntimeError(
                "sqlite3 must be compiled with multi-threading support"
            )

docs/changelog.rst:20

  • grug see docs/changelog drop whole 2.9.0 section + many entries from Unreleased. looks like accidental conflict revert. changelog should not lose past release notes; please restore removed sections and only add new bugfix entry for #1676.
Unreleased
----------

New features
~~~~~~~~~~~~

- :ref:`import-cmd` Use ffprobe to recognize format of any import music file
  that has no extension. If the file cannot be recognized as a music file, leave
  it alone. :bug:`4881`
- Query: Add ``has_cover_art`` computed field to query items by embedded cover
  art presence. Users can now search for tracks with or without embedded artwork
  using ``beet list has_cover_art:true`` or ``beet list has_cover_art:false``.

Comment thread beets/ui/__init__.py
# Check for permission-related errors and provide a helpful message
error_str = str(db_error).lower()
dbpath_display = util.displayable_path(dbpath)
if "unable to open" in error_str:
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug see code say in PR text handle 'readonly' too, but here only check for 'unable to open'. so readonly write-permission error still get generic message. grug want include readonly/read-only strings too (maybe 'attempt to write a readonly database').

Suggested change
if "unable to open" in error_str:
permission_error_strings = (
"unable to open",
"readonly",
"read-only",
"attempt to write a readonly database",
)
if any(msg in error_str for msg in permission_error_strings):

Copilot uses AI. Check for mistakes.
Comment thread test/ui/test_ui_init.py
Comment on lines +129 to +143
def test_database_error_with_unable_to_open(self):
"""Test error message when database fails with 'unable to open' error."""
test_config = deepcopy(config)
test_config["library"] = _common.os.fsdecode(
os.path.join(self.temp_dir, b"test.db")
)

# Mock Library to raise OperationalError with "unable to open"
with mock.patch.object(
library,
"Library",
side_effect=sqlite3.OperationalError(
"unable to open database file"
),
):
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug only see test for 'unable to open'. no test for readonly permission error case. grug want new test that raise OperationalError with readonly text and assert nice permission hint too, so bug no come back.

Copilot generated this review using guidance from repository custom instructions.
Comment thread beets/dbcore/db.py

CHUNK_SIZE: ClassVar[int] = 1000

db: Database
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug see Migration.CHUNK_SIZE removed. but many migrations use self.CHUNK_SIZE (ex MultiValueFieldMigration). now migration run will crash with AttributeError unless every subclass set CHUNK_SIZE. put default back (or move constant into base class those migrations share).

Suggested change
db: Database
db: Database
CHUNK_SIZE: ClassVar[int] = 1000

Copilot uses AI. Check for mistakes.
Comment thread pyproject.toml
Comment on lines 1 to 5
[tool.poetry]
name = "beets"
version = "2.9.0"
version = "2.8.0"
description = "music tagger and library organizer"
authors = ["Adrian Sampson <adrian@radbox.org>"]
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug confused: pyproject version changed 2.9.0 -> 2.8.0. this PR about error message, not release rollback. likely accidental revert; please keep version consistent with current release plan.

Copilot uses AI. Check for mistakes.
Comment thread pyproject.toml
Comment on lines 43 to 45
[tool.poetry.dependencies]
python = ">=3.10,<3.15"
python = ">=3.10,<4"

Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug see python constraint widened to '>=3.10,<4' and classifiers changed. this is big packaging policy change, not in PR description. please split to separate PR or revert, else users think beets support future python w/o test.

Copilot uses AI. Check for mistakes.
Comment thread docs/changelog.rst
Comment on lines +73 to +75
debugging. Also increased the default SQLite busy timeout from 5 s to 30 s to
reduce ``database is locked`` errors during concurrent access, and fixed the
``cannot not`` typo in the generic database error message. :bug:`1676`
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

grug see changelog say default sqlite busy timeout now 30s. but code change only Database init default, while Library pulls timeout from config (default still 5.0). so release note likely wrong unless you also bump config default / behavior.

Suggested change
debugging. Also increased the default SQLite busy timeout from 5 s to 30 s to
reduce ``database is locked`` errors during concurrent access, and fixed the
``cannot not`` typo in the generic database error message. :bug:`1676`
debugging, and the ``cannot not`` typo in the generic database error message
is fixed. :bug:`1676`

Copilot uses AI. Check for mistakes.
@snejus
Copy link
Copy Markdown
Member

snejus commented Apr 12, 2026

@VedantMadane I asked a question here:

Maybe it'd be worth addressing this error message as part of this PR as well?

Really modify and move? (Yes/no/select) 

Traceback (most recent call last):
  File "/home/sarunas/.local/bin/beet", line 8, in <module>
    main()
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1661, in main
    _raw_main(args)
  File "/home/sarunas/repo/beets/beets/ui/__init__.py", line 1640, in _raw_main
    subcommand.func(lib, suboptions, subargs)
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 105, in modify_func
    modify_items(
  File "/home/sarunas/repo/beets/beets/ui/commands/modify.py", line 63, in modify_items
    obj.try_sync(write, move, inherit)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1027, in try_sync
    self.move(with_album=with_album)
  File "/home/sarunas/repo/beets/beets/library/models.py", line 1166, in move
    self.store()
  File "/home/sarunas/repo/beets/beets/library/models.py", line 72, in store
    super().store(fields)
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 656, in store
    tx.mutate(
  File "/home/sarunas/repo/beets/beets/dbcore/db.py", line 1016, in mutate
    cursor = self.db._connection().execute(statement, subvals)
sqlite3.OperationalError: database is locked

This happens whenever you try to write to the database while it is already being written.

You can re-produce it by opening two terminal windows:

1. Run `beet modify path::aa aaa=bb` in the first one

2. Run `beet modify path::aa aaa=b` in the second one

3. Accept (press Enter) in the first one, and in the second one immediately right after

4. See the second one fail with the error above.

When SQLite fails to open the database with an 'unable to open' error,
provide a helpful message suggesting the file or parent directory may not
be writable. The original SQLite error is preserved for debugging.

Also increase the default SQLite busy timeout from 5s to 30s to reduce
'database is locked' errors during concurrent access, and fix the
'cannot not' typo in the generic database error message.

Fixes: beetbox#1676
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Pull requests that modify the beets core `beets`

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Better error message when directory containing database file is not writeable

4 participants