diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 167ee2d..ba09862 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -44,7 +44,7 @@ body: seriousdb version or commit: Python version: Operating system: - Installation method (uv, Nix, or other): + Installation method (uv or other): - type: textarea id: context attributes: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2b33dd3..76f904d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v10.1.0 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: python-version: "3.11" enable-cache: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cebf128..c9fd3dc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v10.1.0 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: python-version: "3.11" enable-cache: true diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml index 8b6edbd..20ff4b6 100644 --- a/.github/workflows/lint_pr.yml +++ b/.github/workflows/lint_pr.yml @@ -18,7 +18,7 @@ jobs: steps: # Lint the pull request message and throw an error if it is not alligned # with the conventional commit default rules (e.g. : description) - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 id: lint_pr_title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -26,7 +26,7 @@ jobs: # When the previous steps fails, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. - if: always() && (steps.lint_pr_title.outputs.error_message != null) - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: header: pr-title-lint-error message: | @@ -39,7 +39,7 @@ jobs: # Delete a previous comment when the issue has been resolved - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 with: header: pr-title-lint-error delete: true \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0d3629f..bd5605f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,11 +11,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: + python-version: "3.11" enable-cache: true - name: Install project diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index df1deed..01628f4 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v10.1.0 + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: python-version: "3.11" enable-cache: true diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 2db0c55..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,67 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## [Unreleased] - -### Removed - -- `VERSION` file; the README version badge now reads the version from PyPI ([#301](https://github.com/danieldeer/seriousdb/pull/301)). - -## [0.2.2] - 2026-09-25 - -> [!NOTE] -> 0.2.0 and 0.2.1 were published to PyPI on the same day and superseded by -> 0.2.2 within minutes. They are not listed separately; this entry covers all -> changes since 0.1.0. - -### Added - -- Python module-level API: `seriousdb.get`, `set`, `delete`, `exists`, `get_all`, `get_bulk` and `count` let other Python projects use SeriousDB as an embedded storage layer ([#227](https://github.com/danieldeer/seriousdb/pull/227), [#287](https://github.com/danieldeer/seriousdb/pull/287)). -- Performance benchmarks for the storage engine, including multi-process workloads ([#215](https://github.com/danieldeer/seriousdb/pull/215), [#263](https://github.com/danieldeer/seriousdb/pull/263)). -- API reference generated with Sphinx under `docs/reference/`, checked for staleness in CI ([#283](https://github.com/danieldeer/seriousdb/pull/283)). -- Conventional Commits enforced by a pre-commit hook and a pull request title check in CI ([#238](https://github.com/danieldeer/seriousdb/pull/238)). -- Project files: `CHANGELOG.md`, `CODE_OF_CONDUCT.md`, `CONTRIBUTORS`, `SECURITY.md`, `TODO.md` and `VERSION` ([#33](https://github.com/danieldeer/seriousdb/pull/33)), an AI usage policy in `AGENTS.md` ([#289](https://github.com/danieldeer/seriousdb/pull/289)), `.editorconfig` ([#245](https://github.com/danieldeer/seriousdb/pull/245)) and README badges ([#265](https://github.com/danieldeer/seriousdb/pull/265)). - -### Changed - -- Persistence uses a write-ahead log instead of rewriting the whole database file after every change. Each write is durably appended to `.wal` before it returns, and the log is periodically compacted into the JSON snapshot ([#258](https://github.com/danieldeer/seriousdb/pull/258)). -- Documentation updated to describe the Python API ([#240](https://github.com/danieldeer/seriousdb/pull/240), [#262](https://github.com/danieldeer/seriousdb/pull/262), [#267](https://github.com/danieldeer/seriousdb/pull/267), [#274](https://github.com/danieldeer/seriousdb/pull/274)). - -### Fixed - -- The database file is written atomically, so a crash while saving or while creating the default database no longer leaves a corrupt file ([3208b11](https://github.com/danieldeer/seriousdb/commit/3208b11), [#279](https://github.com/danieldeer/seriousdb/pull/279)). -- Backups of corrupt database files are no longer overwritten when two are created within the same second ([#260](https://github.com/danieldeer/seriousdb/pull/260)). - -### Removed - -- The HTTP server, the FastAPI dependency, the Docker image and the `SERIOUSDB_HOST` and `SERIOUSDB_PORT` settings. SeriousDB is now used as a Python library ([#246](https://github.com/danieldeer/seriousdb/pull/246), [#282](https://github.com/danieldeer/seriousdb/pull/282)). - -## [0.1.0] - 2026-09-18 - -First release. SeriousDB 0.1.0 is a FastAPI service that keeps a key-value -store in memory and persists it to a JSON file. - -### Added - -- HTTP API on `/db`: - - `PUT /db` stores a value, returning `201` for a new key and `200` when overwriting ([#162](https://github.com/danieldeer/seriousdb/pull/162)). - - `GET /db` returns a value; `HEAD /db` checks whether a key exists ([#37](https://github.com/danieldeer/seriousdb/issues/37)). - - `DELETE /db` removes a key and returns its previous value ([#121](https://github.com/danieldeer/seriousdb/pull/121), [#150](https://github.com/danieldeer/seriousdb/pull/150)). - - `GET /db/all`, `GET /db/bulk` ([#203](https://github.com/danieldeer/seriousdb/pull/203)) and `GET /db/count` ([#180](https://github.com/danieldeer/seriousdb/pull/180)). -- `GET /health` readiness endpoint ([#107](https://github.com/danieldeer/seriousdb/pull/107)). -- JSON persistence to a `.sdb` file, written in the background after each change ([#84](https://github.com/danieldeer/seriousdb/pull/84)). -- Recovery from corrupt database files: an unreadable or non-object file is moved to `.corrupt-` and a fresh database is created ([#72](https://github.com/danieldeer/seriousdb/pull/72), [#85](https://github.com/danieldeer/seriousdb/pull/85)). -- Consistent error responses: `404` for missing keys, `503` when the database is unavailable ([#44](https://github.com/danieldeer/seriousdb/pull/44), [#116](https://github.com/danieldeer/seriousdb/pull/116), [#119](https://github.com/danieldeer/seriousdb/pull/119)). -- Configuration through environment variables or a `.env` file: `SERIOUSDB_DB_FILE`, `SERIOUSDB_LOG_LEVEL`, `SERIOUSDB_HOST` and `SERIOUSDB_PORT` ([#151](https://github.com/danieldeer/seriousdb/pull/151)). The server binds to `127.0.0.1` by default ([#129](https://github.com/danieldeer/seriousdb/pull/129)). -- Central logging configuration ([#200](https://github.com/danieldeer/seriousdb/pull/200)). -- Docker image with a multi-stage build ([#49](https://github.com/danieldeer/seriousdb/pull/49), [#86](https://github.com/danieldeer/seriousdb/pull/86)). -- Nix development environment ([#67](https://github.com/danieldeer/seriousdb/pull/67), [#118](https://github.com/danieldeer/seriousdb/pull/118)). -- Documentation for the API, architecture, configuration, persistence, testing and contributing, plus OpenAPI metadata for all endpoints ([#28](https://github.com/danieldeer/seriousdb/pull/28), [#198](https://github.com/danieldeer/seriousdb/pull/198)). -- Unit, API and end-to-end tests, with CI for tests, Ruff linting and `ty` type checking ([#27](https://github.com/danieldeer/seriousdb/pull/27), [#29](https://github.com/danieldeer/seriousdb/pull/29), [#105](https://github.com/danieldeer/seriousdb/pull/105), [#159](https://github.com/danieldeer/seriousdb/pull/159), [#195](https://github.com/danieldeer/seriousdb/pull/195)). - -[Unreleased]: https://github.com/danieldeer/seriousdb/compare/0.2.2...HEAD -[0.2.2]: https://github.com/danieldeer/seriousdb/releases/tag/0.2.2 -[0.1.0]: https://pypi.org/project/seriousdb/0.1.0/ diff --git a/README.md b/README.md index a8c7d9d..b586699 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ cp .env.example .env The `.env` file is gitignored and should never be committed. | Variable | Default | Description | -|-----------------------|---------|----------------------------------------------------| +| --------------------- | ------- | -------------------------------------------------- | | `SERIOUSDB_DB_FILE` | `.sdb` | Path to the on-disk database file. | | `SERIOUSDB_LOG_LEVEL` | `INFO` | Logging level (DEBUG/INFO/WARNING/ERROR/CRITICAL). | @@ -80,7 +80,6 @@ Set configuration before importing `seriousdb`. - [Contributing](docs/contributing.md) - [Testing](docs/testing.md) - [Project configuration](docs/configuration.md) -- [Changelog](CHANGELOG.md) ## License diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 8ad7bb6..0000000 --- a/TODO.md +++ /dev/null @@ -1,5 +0,0 @@ -# TODO - -## General - -- [] Choose logo \ No newline at end of file diff --git a/docs/development.md b/docs/development.md index aeceebb..b63702a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,102 +33,40 @@ seriousdb.set("name", "Alice") seriousdb.get("name") ``` -## Nix - -> The project provides a Nix development environment with the tools required for local development. - -### Enter Nix develop - -From the project root, run: - -```bash -nix develop -``` - -This starts a shell with the project's development dependencies available. - -### Nix (in the Nix Shell) - -```bash -python -``` - -Then import and use `seriousdb` as shown above. - ## Formatting ### Format Python files with `ruff`: -#### uv - ```bash uv run ruff format . ``` -### Nix (in Nix Shell) - -```bash -ruff format . -``` - ### To check formatting without changing files: -#### uv - ```bash uv run ruff format --check . ``` -### Nix (in Nix Shell) - -```bash -ruff format --check . -``` - ## Linting ### Lint python files with `ruff`: -#### uv - ```bash uv run ruff check . ``` -#### Nix (in Nix Shell) - -```bash -ruff check . -``` - ### Type checking with `ty` -#### uv - ```bash uv run ty check . ``` -#### Nix (in Nix Shell) - -```bash -ty check . -``` - ### To fix linter errors and warning if possible run following command: -#### uv - ```bash uv run ruff check --fix . ``` -### Nix (in Nix Shell) - -```bash -ruff check --fix . -``` - ## Docstrings Python docstrings follow the [NumPy style](https://numpydoc.readthedocs.io/en/latest/format.html). @@ -178,19 +116,11 @@ files under it by hand: edit the docstring instead and regenerate. ### Regenerate -#### uv - ```bash uv sync --group docs uv run --group docs python scripts/docs-reference.py ``` -#### Nix (in Nix Shell) - -```bash -python scripts/docs-reference.py -``` - Commit any resulting changes under `docs/reference/` along with your docstring change. CI (see [`.github/workflows/docs.yml`](../.github/workflows/docs.yml)) rebuilds it and fails the check if the committed output doesn't match, so a stale reference is caught before merge. diff --git a/flake.lock b/flake.lock deleted file mode 100644 index fc28473..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1789006805, - "narHash": "sha256-xB8mKMOx1IA9vTDNLmJZ6n4wCMq/cuWBBOzGCRnqxrU=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "8ce4ef6cb6f871616146b9fe26d2a5ae594e94fe", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index d760e81..0000000 --- a/flake.nix +++ /dev/null @@ -1,45 +0,0 @@ -{ - description = "Development environment for seriousdb"; - - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - outputs = - { nixpkgs, ... }: - let - systems = [ - "x86_64-linux" - "aarch64-linux" - "aarch64-darwin" - ]; - - forAllSystems = nixpkgs.lib.genAttrs systems; - in - { - devShells = forAllSystems ( - system: - let - pkgs = import nixpkgs { inherit system; }; - - python = pkgs.python314.withPackages ( - pythonPackages: with pythonPackages; [ - python-dotenv - - pytest - pytest-benchmark - ruff - ty - ] - ); - in - { - default = pkgs.mkShell { - packages = [ python ]; - - shellHook = '' - export PYTHONPATH="${toString ./.}/src''${PYTHONPATH:+:$PYTHONPATH}" - ''; - }; - } - ); - }; -} diff --git a/src/seriousdb/b_plus_tree.py b/src/seriousdb/b_plus_tree.py new file mode 100644 index 0000000..ddd9cf5 --- /dev/null +++ b/src/seriousdb/b_plus_tree.py @@ -0,0 +1,238 @@ +"""b+tree implementation for datalookups in O(log n). + +this module will hold the b+tree datastructure for loading and deleting data in and out of memory +NO function is currently thread-safe in this class. +""" + +from __future__ import annotations + + +class _Node: + """internal class representing a node from the b+Tree.""" + + _parent: _InternalNode | None = None + _keys: list[int] + + def __init__(self) -> None: + self._keys = [] + + +class _LeafNode(_Node): + """internal class representing a _leafnode from the b+Tree.""" + + _values: list + _next_leaf: _LeafNode | None = None + + def __init__(self) -> None: + super().__init__() + self._values = [] + + +class _InternalNode(_Node): + """internal class representing.""" + + _children: list[_Node] + + def __init__(self) -> None: + super().__init__() + self._children = [] + + +class bPlusTree: + """a Btree like Datastructure to retrieve and put data in O(log n) time. + + the public functions will firstly be get() put() delete() range() + + !IMPORTANT: this structure is currently not threadsafe. + + Parameters + ---------- + _maxChildren :int + the tree takes in the maximum number of children a internal node should have. + + Attributes + ---------- + _maxChildren : int + The maximum number of children (mostly in literature visualized as m) an internal Node can point to + _maxKeys: int + The maximum number of Keys a _Node can hold derrived from the maximum number of children (m -1) + _root : _Node or None + the root Node of the tree the first will be a leafNode and after enough insertions mostly an internal Node + """ + + _maxChildren: int = 0 + _maxKeys: int = 0 + _root: _LeafNode | _InternalNode | None = None + + def __init__(self, maxChildren: int) -> None: + self._maxChildren = maxChildren + self._maxKeys = self._maxChildren - 1 + + def put(self, key: int, value: bytes) -> bool: + """Put an key value pair into the tree. + + if the tree is empty a leafNode is created at root and filled with the first key and value. + if the tree alereay has some nodes the search starts. The next node from the children gets chosen via the comparator keys. + If a leafnode has to many values or keys put splits the leafnote. + + Parameters + ---------- + key : int + the key to access the value. + value : bytes + bytes (Pages) to store. + + Returns + ------- + is_new_key : bool + ``True`` if `key` did not exist before, ``False`` if an existing + value was replaced. + """ + if self._root is None: + self._root = self._createLeaf(key, value) + return True + + node: _Node | _LeafNode | _InternalNode = self._root + while True: + if isinstance(node, _LeafNode): + if key in node._keys: + key_index = node._keys.index(key) + node._values[key_index] = value + return False + + insert_index: int = self._find_position(node._keys, key) + + node._keys.insert(insert_index, key) + node._values.insert(insert_index, value) + + if len(node._keys) > self._maxKeys: + self._split(node) + return True + + if isinstance(node, _InternalNode): + internalNode: _InternalNode = node + nodeIndex = self._find_position(internalNode._keys, key) + node = internalNode._children[nodeIndex] + + def get(key: int) -> bytes | None: + """Return a value from the given key. if the key does not exist in the tree this function returns None. + + Parameters + ---------- + key : int + the key to access the value. + + Returns + ------- + value : bytes | None + returns bytes if the key is in the tree otherwise this function returns None + """ + return + + def delete(key: int) -> bool: + """Delete algorithm deletes key value pair.""" + return True + + """------ INTERNAL FUNCTIONS ------""" + + def _createLeaf(self, key: int, value) -> _LeafNode: + node = _LeafNode() + node._keys.append(key) + key_index = node._keys.index(key) + node._values.insert(key_index, value) + return node + + def _find_position(self, keys: list[int], key: int) -> int: + insert_index: int = 0 + + while insert_index <= len(keys) and keys[insert_index] < key: + insert_index += 1 + + return insert_index + + def _split_leaf(self, node: _LeafNode) -> None: + newLeaf: _LeafNode = _LeafNode() + keyLen: int = len(node._keys) + splitIndex: int = keyLen // 2 + + newLeaf._keys = node._keys[splitIndex:] + newLeaf._values = node._values[splitIndex:] + + node._keys = node._keys[:splitIndex] + node._values = node._values[:splitIndex] + + newLeaf._next_leaf = node._next_leaf + node._next_leaf = newLeaf + + newSeperator: int = newLeaf._keys[0] + + if node._parent is not None: + parent: _InternalNode = node._parent + + child_index: int = parent._children.index(node) + parent._children.insert(child_index + 1, newLeaf) + parent._keys.insert(child_index, newSeperator) + newLeaf._parent = parent + + if len(parent._keys) > self._maxKeys: + self._split(parent) + + else: + # if the parent of an leafnote is None it is guaranteed that the leafnote lies at root. + internalNode: _InternalNode = _InternalNode() + internalNode._keys.append(newSeperator) + internalNode._children.append(node) + internalNode._children.append(newLeaf) + + newLeaf._parent = internalNode + node._parent = internalNode + self._root = internalNode + + def _split_internal(self, node: _InternalNode) -> None: + oldNode = node + newNode = _InternalNode() + + keyLen: int = len(node._keys) + splitIndex: int = keyLen // 2 + + tempKey = oldNode._keys[splitIndex] + newNode._keys = oldNode._keys[splitIndex + 1 :] + oldNode._keys = oldNode._keys[:splitIndex] + + newNode._children = oldNode._children[splitIndex + 1 :] + oldNode._children = oldNode._children[: splitIndex + 1] + + # fix child parent pointers + + for child in newNode._children: + child._parent = newNode + + if oldNode._parent is not None: + parent: _InternalNode = oldNode._parent + + child_index: int = parent._children.index(oldNode) + parent._children.insert(child_index + 1, newNode) + parent._keys.insert(child_index, tempKey) + newNode._parent = parent + + if len(parent._keys) > self._maxKeys: + self._split(parent) + else: + internalNode: _InternalNode = _InternalNode() + internalNode._keys.append(tempKey) + + internalNode._children.append(oldNode) + internalNode._children.append(newNode) + + newNode._parent = internalNode + oldNode._parent = internalNode + + self._root = internalNode + + def _split(self, node: _Node) -> None: + + if isinstance(node, _LeafNode): + self._split_leaf(node) + + if isinstance(node, _InternalNode): + self._split_internal(node) diff --git a/src/seriousdb/cache.py b/src/seriousdb/cache.py index 93d7057..bca4180 100644 --- a/src/seriousdb/cache.py +++ b/src/seriousdb/cache.py @@ -83,7 +83,7 @@ def insert(self, key: str, value: str) -> tuple[str, bool]: If the write-ahead log cannot be written. `self.db` is left unchanged in this case. """ with self.lock: - db = require_db(self) + db = self._require_db() is_new_key = key not in db self._record_write(SetEntry(key=key, value=value)) db[key] = value @@ -111,7 +111,7 @@ def select(self, key: str) -> str: If no database has been loaded. """ with self.lock: - val = require_db(self).get(key, None) + val = self._require_db().get(key, None) if val is None: logger.debug("Key not found: %s", key) raise ResourceNotFoundError(f"No value set for key {key}") @@ -145,7 +145,7 @@ def delete(self, key: str) -> str: If the write-ahead log cannot be written. `self.db` is left unchanged in this case. """ with self.lock: - db = require_db(self) + db = self._require_db() val = db.get(key, None) if val is not None: self._record_write(DeleteEntry(key=key)) @@ -175,7 +175,7 @@ def exists(self, key: str) -> bool: If no database has been loaded. """ with self.lock: - return key in require_db(self) + return key in self._require_db() def __contains__(self, key: str) -> bool: """Return whether `key` exists in the database. @@ -211,7 +211,7 @@ def get_all(self) -> dict[str, str]: If no database has been loaded. """ with self.lock: - return require_db(self).copy() + return self._require_db().copy() def get_bulk(self, keys: Iterable[str]) -> dict[str, str]: """Return the values stored under multiple keys. @@ -235,7 +235,7 @@ def get_bulk(self, keys: Iterable[str]) -> dict[str, str]: """ key_list = tuple(keys) with self.lock: - db = require_db(self) + db = self._require_db() return {key: db[key] for key in key_list if key in db} def count(self) -> int: @@ -252,7 +252,7 @@ def count(self) -> int: If no database has been loaded. """ with self.lock: - return len(require_db(self)) + return len(self._require_db()) def __len__(self) -> int: """Return the number of key-value pairs in the database. @@ -325,7 +325,7 @@ def load(self, filename: str) -> None: replayed = self.wal.replay() self._writes_since_compact = len(replayed) for entry in replayed: - entry.apply(require_db(self)) + entry.apply(self._require_db()) def flush(self) -> None: """No-op, kept for backward compatibility. @@ -355,7 +355,7 @@ def _record_write(self, entry: WalEntry) -> None: OSError If the write-ahead log cannot be written. """ - require_wal(self).append(entry) + self._require_wal().append(entry) self._writes_since_compact += 1 def _safe_maybe_compact(self) -> None: @@ -399,6 +399,53 @@ def _compact(self) -> None: self._writes_since_compact = 0 + def _require_db(self) -> dict[str, str]: + """Validate and return the loaded data. + + The caller must hold ``lock`` while using the returned ``dict``. + + Returns + ------- + dict of str to str + The loaded key-value pairs. This is the cache's own ``dict``, not a + copy. + + Raises + ------ + ServiceUnavailableError + If no database is loaded. + """ + if self.db is None: + logger.error("Database unavailable: %s", self.filename) + raise ServiceUnavailableError( + f"Database file {self.filename} could not be opened and loaded" + ) + + return self.db + + def _require_wal(self) -> WriteAheadLog: + """Validate and return the write-ahead log. + + The caller must hold ``lock`` while using the returned log. + + Returns + ------- + WriteAheadLog + The cache's write-ahead log. + + Raises + ------ + ServiceUnavailableError + If no database is loaded. + """ + if self.wal is None: + logger.error("Write-ahead log unavailable: %s", self.filename) + raise ServiceUnavailableError( + f"Database file {self.filename} could not be opened and loaded" + ) + + return self.wal + def _atomic_write_json(filename: str, data: dict[str, str]) -> None: """Write `data` to `filename` atomically, via a temp file and `os.replace`. @@ -456,62 +503,3 @@ def _generate_corrupt_backup_path(filename: str) -> str: while os.path.lexists(f"{base}-{counter}"): counter += 1 return f"{base}-{counter}" - - -def require_db(cache: Cache) -> dict[str, str]: - """Return the loaded data of `cache`. - - The caller must hold ``cache.lock`` while using the returned ``dict``. - - Parameters - ---------- - cache : Cache - Cache to read the data from. - - Returns - ------- - dict of str to str - The loaded key-value pairs. This is the cache's own ``dict``, not a - copy. - - Raises - ------ - ServiceUnavailableError - If `cache` has no database loaded. - """ - if cache.db is None: - logger.error("Database unavailable: %s", cache.filename) - raise ServiceUnavailableError( - f"Database file {cache.filename} could not be opened and loaded" - ) - - return cache.db - - -def require_wal(cache: Cache) -> WriteAheadLog: - """Return the write-ahead log of `cache`. - - The caller must hold ``cache.lock`` while using the returned log. - - Parameters - ---------- - cache : Cache - Cache to read the write-ahead log from. - - Returns - ------- - WriteAheadLog - The cache's write-ahead log. - - Raises - ------ - ServiceUnavailableError - If `cache` has no database loaded. - """ - if cache.wal is None: - logger.error("Write-ahead log unavailable: %s", cache.filename) - raise ServiceUnavailableError( - f"Database file {cache.filename} could not be opened and loaded" - ) - - return cache.wal diff --git a/tests/test_b_plus_tree.py b/tests/test_b_plus_tree.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cache.py b/tests/test_cache.py index 97ea5ea..2ca8ebb 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,7 +7,7 @@ import pytest from pytest import MonkeyPatch -from seriousdb.cache import COMPACTION_THRESHOLD, Cache, require_db +from seriousdb.cache import COMPACTION_THRESHOLD, Cache from seriousdb.exceptions import ResourceNotFoundError, ServiceUnavailableError from seriousdb.wal import SetEntry @@ -145,7 +145,7 @@ def test_load_rejects_non_object_json(db_path): def test_require_db_returns_loaded_database(cache): - assert require_db(cache) is cache.db + assert cache._require_db() is cache.db def test_require_db_raises_when_database_is_not_loaded(): @@ -156,7 +156,7 @@ def test_require_db_raises_when_database_is_not_loaded(): ServiceUnavailableError, match=r"\.sdb", ): - require_db(cache) + cache._require_db() def test_compaction_triggers_at_write_threshold(db_path): diff --git a/tests/test_logging.py b/tests/test_logging.py index 4f48462..5ebb638 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -4,7 +4,7 @@ import pytest -from seriousdb.cache import Cache, require_db +from seriousdb.cache import Cache from seriousdb.exceptions import ServiceUnavailableError from seriousdb.logging_config import configure_logging @@ -96,9 +96,9 @@ def test_require_db_raises_when_db_is_none(self): cache = Cache() cache.filename = "missing.sdb" with pytest.raises(ServiceUnavailableError, match="missing.sdb"): - require_db(cache) + cache._require_db() def test_require_db_returns_db_when_loaded(self): cache = Cache() cache.db = {"a": "b"} - assert require_db(cache) is cache.db + assert cache._require_db() is cache.db