From c82c274da332f9ed8d43ab156fd9bb2598f3f71b Mon Sep 17 00:00:00 2001 From: mattsva Date: Fri, 18 Sep 2026 19:02:20 +0200 Subject: [PATCH 1/2] build: update nix development environment * build: complete Nix development environment * build: configure ty for project source layout * docs: update development guide with Nix workflow --- docs/development.md | 51 +++++++++++++++++++++++++++++++++++---------- flake.nix | 10 ++++++++- pyproject.toml | 3 +++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/development.md b/docs/development.md index c47b706..2221292 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,13 +25,19 @@ uv run run.py ## 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 ``` -## Starting in Nix develop +This starts a shell with the project's development dependencies available. + +## Nix (in the Nix Shell) ```bash python run.py @@ -50,50 +56,73 @@ The readiness endpoint is available at `/health`. ## Formatting -Format Python files with `ruff`: +### Format Python files with `ruff`: + +#### uv ```bash uv run ruff format . ``` -To check formatting without changing files: +### Nix (in Nix Shell) + +```bash +ruff format . +``` + +### To check formatting without changing files: + +#### uv ```bash uv run ruff format --check . ``` -### Nix in Nix develop +### Nix (in Nix Shell) ```bash -ruff format . +ruff format --check . ``` ## Linting -Lint python files with `ruff`: +### Lint python files with `ruff`: + +#### uv ```bash uv run ruff check . ``` -Type checking with `ty`: +#### Nix (in Nix Shell) + +```bash +ruff check . +``` + +### Type checking with `ty` + +#### uv + ```bash uv run ty check . ``` -### Nix in Nix develop +#### Nix (in Nix Shell) ```bash -ruff check . +ty check . ``` -To fix linter errors and warning if possible run following command: +### To fix linter errors and warning if possible run following command: + +#### uv ```bash uv run ruff check --fix . ``` -### Nix in Nix develop +### Nix (in Nix Shell) ```bash ruff check --fix . diff --git a/flake.nix b/flake.nix index 30a9792..715692b 100644 --- a/flake.nix +++ b/flake.nix @@ -19,17 +19,25 @@ system: let pkgs = import nixpkgs { inherit system; }; + python = pkgs.python314.withPackages ( pythonPackages: with pythonPackages; [ - ruff fastapi fastapi-cli + python-dotenv + uvicorn + + pytest + pytest-benchmark + ruff + ty ] ); in { default = pkgs.mkShell { packages = [ python ]; + shellHook = '' export PYTHONPATH="${toString ./.}/src''${PYTHONPATH:+:$PYTHONPATH}" ''; diff --git a/pyproject.toml b/pyproject.toml index 07d91ec..fc72198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,3 +64,6 @@ convention = "numpy" [tool.ruff.format] docstring-code-format = true + +[tool.ty.environment] +root = ["."] From f2ada0c6579d164aa2b205626f080e68ae575763 Mon Sep 17 00:00:00 2001 From: Mohammed Fawzy <84591716+mohammedFawzy0111@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:11:38 +0300 Subject: [PATCH 2/2] refactor: Add Python module level API (#227) * Add Python module level API * add api documentation * fixing typos, and unexposing internal methods in __init__.py, api.py * fix typo * unexposed load * corect formating --- README.md | 25 ++++ docs/api.md | 42 ++++++- src/seriousdb/__init__.py | 30 ++++- src/seriousdb/api.py | 246 ++++++++++++++++++++++++++++++++++++++ tests/test_python_api.py | 194 ++++++++++++++++++++++++++++++ 5 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 src/seriousdb/api.py create mode 100644 tests/test_python_api.py diff --git a/README.md b/README.md index f32f0c7..195ebf8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,31 @@ For setup, usage, architecture, persistence, and contribution guidance, see the ## Quick Start +### Use as Python library + +Install using + +- pip: +```bash +pip install git+https://github.com/danieldeer/seriousdb.git +``` +Or +- uv: +```bash +uv add git+https://github.com/danieldeer/seriousdb.git +``` + +Then use it directly form your python project: + +```python +import seriousdb + +seriousdb.set("name", "Alice") +print(seriousdb.get("name")) +``` + +### Run as HTTP server + Clone the repository, install the project, and start the development server: ```bash diff --git a/docs/api.md b/docs/api.md index 96d5e99..3f422de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,46 @@ # API reference -The server exposes a small HTTP API through FastAPI. +seriousdb can be used as a storage layer directly from Python, or through +the small HTTP API exposed by the FastAPI server. + +## Python API + +Other Python projects can import seriousdb and call its functions directly. + +```python +import seriousdb + +seriousdb.set("name", "Alice") +seriousdb.get("name") +``` + +All functions operate on a single shared cache and are thread-safe. +The database file (default: `.sdb`) is loaded automatically on the first call. +Use `seriousdb.load(path)` to load a different file explicitly. + +`set` and `delete` flush the database file before they return, so a successful call is persisted. + +| Function | Description | +| :--- | :--- | +| `get(key)` | Return the value stored under `key`. Raises `ResourceNotFoundError` if the key does not exist. | +| | | +| `set(key, value)` | Store `value` under `key`, overwriting any existing value. Returns the stored value. | +| | | +| `delete(key)` | Remove `key` and return its previous value. Raises `ResourceNotFoundError` if the key does not exist. | +| | | +| `exists(key)` | Return whether `key` exists. | +| | | +| `get_all()` | Return a snapshot of every key-value pair. | +| | | +| `get_bulk(keys)` | Return the values for multiple keys; missing keys are omitted. | +| | | +| `count()` | Return the number of stored key-value pairs. | + +The Python API raises the same application exceptions as the HTTP layer, +e.g. `seriousdb.exceptions.ResourceNotFoundError`. + + +## HTTP API Interactive OpenAPI documentation is available at `http://127.0.0.1:8000/docs` while the server is running. diff --git a/src/seriousdb/__init__.py b/src/seriousdb/__init__.py index 89a2f66..84729c9 100644 --- a/src/seriousdb/__init__.py +++ b/src/seriousdb/__init__.py @@ -1 +1,29 @@ -"""SeriousDB, a small key-value store served over HTTP with FastAPI.""" +"""SeriousDB, a small persistent key-value database. + +seriousdb can be used as a storage layer from other Python projects. +>>> import seriousdb as sdb +>>> sdb.set("name", "Alice") +'Alice' +>>> sdb.get("name") +'Alice' +""" + +from seriousdb.api import ( + count, + delete, + exists, + get, + get_all, + get_bulk, + set, +) + +__all__ = [ + "count", + "delete", + "exists", + "get", + "get_all", + "get_bulk", + "set", +] diff --git a/src/seriousdb/api.py b/src/seriousdb/api.py new file mode 100644 index 0000000..5e8d527 --- /dev/null +++ b/src/seriousdb/api.py @@ -0,0 +1,246 @@ +"""Synchronous Python API of seriousdb. + +This module is the storage layer of seriousdb: other Python projects can +import the package and call the functions exported here directly, without +going through the HTTP interface. + +All functions operate on a single, module-level +:class:`~seriousdb.cache.Cache` that is shared with the HTTP server. The +database file (by default ``.sdb``, see :mod:`seriousdb.config`) is loaded +automatically on the first call; use :func:`load` to load a different file +explicitly. + +:func:`set` and :func:`delete` flush the database file before they return, +so a successful call is persisted. All functions are thread-safe. +""" + +from collections.abc import Iterable +from pathlib import Path +from threading import Lock + +from .cache import Cache, require_db +from .config import DB_FILE + +__all__ = [ + "count", + "delete", + "exists", + "get", + "get_all", + "get_bulk", + "set", +] + +cache = Cache() + +_init_lock = Lock() + + +def load(filename: str | Path = DB_FILE) -> None: + """Load the database from `filename`, replacing the current data. + + If the file does not exist, it is created with an empty database. + If it is not valid UTF-8 JSON or does not contain a JSON object, it is + renamed to ``.corrupt-`` and replaced with an empty database. + + Parameters + ---------- + filename : str or Path, optional Path of the database file. + Default to :data:`~seriousdb.config.DB_FILE`. + + Raises + ------ + OSError + if the file cannot be read, renamed or written. + """ + cache.load(str(filename)) + + +def is_loaded() -> bool: + """Return whether a database has been loaded. + + Returns + ------- + bool + ``True`` if a database file has been loaded, + ``False`` otherwise. + """ + return cache.db is not None + + +def _ensure_loaded() -> None: + """Load the default database file if nothing has been loaded yet.""" + if is_loaded(): + return + with _init_lock: + if not is_loaded(): + load() + + +def get(key: str) -> str: + """Return the value stored under `key`. + + the database file is loaded automatically on first use. + + Parameters + ---------- + key: str + Key to look up. + + Returns + ------- + str + The value stored under `key`. + + Raises + ------ + ResourceNotFoundError + If `key` does not exist. + OSError + If the database file cannont be loaded. + """ + _ensure_loaded() + return cache.select(key) + + +def set(key: str, value: str) -> str: + """Store `value` under `key`, overwriting any existing value. + + The change is flushed to the database file before the function returns. + + Parameters + ---------- + key: str + Key to store the value under. + value: str + Value to store + + Returns + ------- + str + The stored value. + + Raises + ------ + OSError + If the database file cannont be loaded or written. + """ + _ensure_loaded() + value, _ = cache.insert(key, value) + cache.flush() + return value + + +def delete(key: str) -> str: + """Remove `key` and return the value it had. + + The change is flushed to the database file before the function returns. + + Parameters + ---------- + key : str + Key to remove. + + Returns + ------- + str + The value `key` had before it was removed. + + Raises + ------ + ResourceNotFoundError + If `key` does not exist. + ServiceUnavailableError + If no database has been loaded. + """ + _ensure_loaded() + value = cache.delete(key) + cache.flush() + return value + + +def exists(key: str) -> bool: + """Return whether `key` exists in the database. + + the database file is loaded automatically on first use. + + Parameters + ---------- + key : str + Key to remove. + + Returns + ------- + bool + ``True`` if key exists, ``False`` otherwise. + """ + _ensure_loaded() + with cache.lock: + return key in require_db(cache) + + +def get_all() -> dict[str, str]: + """Return a snapshot of every key-value pair in the database. + + the database file is loaded automatically on first use. + + Returns + ------- + dict of str to str + All stored key-value pairs. + + Raises + ------ + OSError + If the database file cannont be loaded. + """ + _ensure_loaded() + with cache.lock: + return require_db(cache).copy() + + +def get_bulk(keys: Iterable[str]) -> dict[str, str]: + """Return the values stored under multiple keys. + + Keys that do not exist are omitted from the result. + the database file is loaded automatically on first use. + + Parameters + ---------- + keys: Iterable of str + Keys to lookup. + + Returns + ------- + dict of str to str + A key-value pair for each requested key that exists in the database. + + Raises + ------ + OSError + If the database file cannont be loaded. + """ + _ensure_loaded() + with cache.lock: + db = require_db(cache) + return {key: db[key] for key in keys if key in db} + + +def count() -> int: + """Return the number of key-value pairs int the database. + + the database file is loaded automatically on first use. + + Returns + ------- + int + The number of stored key-value pairs + + Raises + ------ + OSError + If the database file cannont be loaded. + """ + _ensure_loaded() + with cache.lock: + return len(require_db(cache)) diff --git a/tests/test_python_api.py b/tests/test_python_api.py new file mode 100644 index 0000000..bdfec9d --- /dev/null +++ b/tests/test_python_api.py @@ -0,0 +1,194 @@ +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from seriousdb import api +from seriousdb.exceptions import ResourceNotFoundError + + +@pytest.fixture +def db_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + db_file = tmp_path / ".sdb" + with open(db_file, "w") as f: + json.dump({}, f) + api.load(db_file) + yield db_file + api.cache.db = None + api.cache.filename = None + + +def test_set_returns_stored_value(db_file): + assert api.set("name", "Alice") == "Alice" + + +def test_get_returns_stored_value(db_file): + api.set("name", "Alice") + + assert api.get("name") == "Alice" + + +def test_set_overwrites_existing_key(db_file): + api.set("name", "Alice") + + assert api.set("name", "Bob") == "Bob" + assert api.get("name") == "Bob" + + +def test_get_missing_key_raises(db_file): + with pytest.raises(ResourceNotFoundError): + api.get("does_not_exist") + + +def test_delete_returns_previous_value_and_removes_key(db_file): + api.set("name", "Alice") + + assert api.delete("name") == "Alice" + + with pytest.raises(ResourceNotFoundError): + api.get("name") + + +def test_delete_missing_key_raises(db_file): + with pytest.raises(ResourceNotFoundError): + api.delete("does_not_exist") + + +def test_exists_returns_whether_key_exists(db_file): + api.set("name", "Alice") + + assert api.exists("name") + assert not api.exists("does_not_exist") + + +def test_get_all_returns_all_key_value_pairs(db_file): + api.set("name", "Alice") + api.set("language", "Python") + + assert api.get_all() == { + "name": "Alice", + "language": "Python", + } + + +def test_get_bulk_returns_existing_keys_only(db_file): + api.set("name", "Daniel") + api.set("language", "Python") + + assert api.get_bulk(["name", "does_not_exist", "language"]) == { + "name": "Daniel", + "language": "Python", + } + + +def test_get_bulk_returns_empty_dict_when_no_keys_match(db_file): + assert api.get_bulk(["does_not_exist"]) == {} + + +def test_count_returns_number_of_key_value_pairs(db_file): + api.set("name", "Alice") + api.set("language", "Python") + + assert api.count() == 2 + + +def test_count_decreases_after_deleting_key(db_file): + api.set("name", "Alice") + + assert api.count() == 1 + + api.delete("name") + + assert api.count() == 0 + + +def test_set_persists_value_to_database_file(db_file): + api.set("name", "Alice") + + with open(db_file) as f: + assert json.load(f) == {"name": "Alice"} + + +def test_delete_persists_removal_to_database_file(db_file): + api.set("name", "Alice") + api.delete("name") + + with open(db_file) as f: + assert json.load(f) == {} + + +def test_load_replaces_current_data(db_file): + api.set("name", "Alice") + + other_file = db_file.parent / "other.sdb" + with open(other_file, "w") as f: + json.dump({"other": "data"}, f) + + api.load(other_file) + + assert api.get_all() == {"other": "data"} + + +def test_load_creates_file_if_missing(db_file): + new_file = db_file.parent / "new.sdb" + + api.load(new_file) + + assert new_file.exists() + assert api.get_all() == {} + + +def test_load_corrupt_file_starts_fresh(db_file): + db_file.write_bytes(b"not json") + + api.load(db_file) + + assert api.get_all() == {} + assert list(db_file.parent.glob("*.corrupt-*")) + + +def test_values_survive_reload(db_file): + api.set("name", "Alice") + + api.load(db_file) + + assert api.get("name") == "Alice" + + +def test_functions_load_default_file_on_first_use(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + api.cache.db = None + api.cache.filename = None + + try: + api.set("name", "Alice") + + assert (tmp_path / ".sdb").exists() + assert api.get("name") == "Alice" + finally: + api.cache.db = None + api.cache.filename = None + + +def test_is_loaded_reflects_state(db_file): + assert api.is_loaded() + + api.cache.db = None + api.cache.filename = None + + assert not api.is_loaded() + + +def test_concurrent_sets(db_file): + def set_value(number): + return api.set(f"key_{number}", f"value_{number}") + + with ThreadPoolExecutor(max_workers=10) as executor: + values = list(executor.map(set_value, range(10))) + + assert values == [f"value_{number}" for number in range(10)] + assert api.count() == 10 + + for number in range(10): + assert api.get(f"key_{number}") == f"value_{number}"