├── .gitignore ├── dante ├── __init__.py ├── asyncdante.py ├── sync.py └── base.py ├── conftest.py ├── examples ├── hello-async.py ├── hello.py ├── hello-pydantic.py ├── fastapi-example-basic-auth.py ├── fastapi-example-oauth2.py ├── todo.py ├── fastapi-example.py └── fastapi_auth.py ├── pyproject.toml ├── .pre-commit-config.yaml ├── LICENSE ├── tests ├── test_async_models.py ├── test_models.py ├── test_sync.py └── test_async.py ├── .github └── workflows │ └── ci.yml ├── docs ├── development.md └── api.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | coverage.lcov 3 | *.py[co] 4 | __pycache__ 5 | htmlcov/ 6 | *.code-* 7 | dist/ 8 | *.lock 9 | *.db 10 | -------------------------------------------------------------------------------- /dante/__init__.py: -------------------------------------------------------------------------------- 1 | from .asyncdante import Dante as AsyncDante 2 | from .sync import Dante 3 | 4 | __all__ = ["Dante", "AsyncDante"] 5 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | 3 | from dante import AsyncDante 4 | 5 | 6 | @pytest_asyncio.fixture 7 | async def db(): 8 | db = AsyncDante() 9 | yield db 10 | await db.close() 11 | -------------------------------------------------------------------------------- /examples/hello-async.py: -------------------------------------------------------------------------------- 1 | from asyncio import run 2 | from dante import AsyncDante 3 | 4 | 5 | async def main(): 6 | db = AsyncDante("mydatabase.db") 7 | collection = await db["mycollection"] 8 | 9 | data = {"name": "Dante", "text": "Hello World!"} 10 | await collection.insert(data) 11 | 12 | result = await collection.find_one(name="Dante") 13 | print(result["text"]) 14 | 15 | new_data = {"name": "Virgil", "text": "Hello World!"} 16 | await collection.update(new_data, name="Dante") 17 | 18 | await db.close() 19 | 20 | 21 | run(main()) 22 | -------------------------------------------------------------------------------- /examples/hello.py: -------------------------------------------------------------------------------- 1 | from dante import Dante 2 | 3 | # Create 'mydatabase.db' in current directory and open it 4 | # (you can omit the database name to create a temporary in-memory database.) 5 | db = Dante("mydatabase.db") 6 | 7 | # Use 'mycollection' collection (also known as a "table") 8 | collection = db["mycollection"] 9 | 10 | # Insert a dictionary to the database 11 | data = {"name": "Dante", "text": "Hello World!"} 12 | collection.insert(data) 13 | 14 | # Find a dictionary with the specified attribute(s) 15 | result = collection.find_one(name="Dante") 16 | print(result["text"]) 17 | 18 | new_data = {"name": "Virgil", "text": "Hello World!"} 19 | collection.update(new_data, name="Dante") 20 | -------------------------------------------------------------------------------- /examples/hello-pydantic.py: -------------------------------------------------------------------------------- 1 | from dante import Dante 2 | from pydantic import BaseModel 3 | 4 | 5 | class Message(BaseModel): 6 | name: str 7 | text: str 8 | 9 | 10 | # Open the database and get the collection for messages 11 | db = Dante("mydatabase.db") 12 | collection = db[Message] 13 | 14 | # Insert a model to the database 15 | obj = Message(name="Dante", text="Hello world!") 16 | collection.insert(obj) 17 | 18 | # Find a model with the specified attribute(s) 19 | result = collection.find_one(name="Dante") 20 | print(result.text) 21 | 22 | # Find a model in the collection with the attribute name=Dante 23 | # and update (overwrite) it with the new model data 24 | result.name = "Virgil" 25 | collection.update(result, name="Dante") 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dante-db" 3 | version = "0.1.1" 4 | description = "Dante, a document store for Python backed by SQLite" 5 | readme = "README.md" 6 | requires-python = ">=3.9" 7 | dependencies = [ 8 | "aiosqlite>=0.20.0", 9 | "pydantic>=2.8.2", 10 | ] 11 | 12 | [build-system] 13 | requires = ["hatchling"] 14 | build-backend = "hatchling.build" 15 | 16 | [tool.hatch.build.targets.wheel] 17 | packages = ["dante"] 18 | 19 | [tool.uv] 20 | dev-dependencies = [ 21 | "ruff>=0.6.1", 22 | "pytest>=8.3.2", 23 | "pytest-coverage>=0.0", 24 | "pytest-asyncio>=0.23.8", 25 | "pre-commit>=3.8.0", 26 | "coveralls>=4.0.1", 27 | "mypy>=1.11.2", 28 | ] 29 | 30 | [tool.pytest.ini_options] 31 | asyncio_default_fixture_loop_scope = "function" 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | rev: v0.6.4 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | - repo: local 12 | hooks: 13 | # Run the tests 14 | - id: pytest 15 | name: pytest 16 | stages: [commit] 17 | types: [python] 18 | entry: pytest 19 | language: system 20 | pass_filenames: false 21 | - repo: local 22 | hooks: 23 | # Run type checks 24 | - id: mypy 25 | name: mypy 26 | stages: [commit] 27 | types: [python] 28 | entry: mypy dante 29 | language: system 30 | pass_filenames: false 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Senko Rasic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/fastapi-example-basic-auth.py: -------------------------------------------------------------------------------- 1 | # This example demonstrates how use Dante to implement basic auth for FastAPI. 2 | # 3 | # To run this example, you need to install FastAPI: 4 | # 5 | # $ pip install fastapi[standard] 6 | # 7 | # Then, you can run the FastAPI server: 8 | # 9 | # $ cd examples/ 10 | # $ fastapi dev fastapi-example-basic-auth.py 11 | # 12 | # And visit http://localhost:8000/docs to interact with the API. 13 | 14 | from __future__ import annotations 15 | 16 | from typing import Annotated 17 | 18 | from fastapi import Depends, FastAPI 19 | from fastapi_auth import User, create_user, get_current_user_basic, init 20 | from pydantic import BaseModel 21 | 22 | from dante import Dante 23 | 24 | app = FastAPI() 25 | db = Dante("users.db", check_same_thread=False) 26 | users = init(db) 27 | 28 | 29 | class SignupRequest(BaseModel): 30 | username: str 31 | password: str 32 | 33 | 34 | @app.get("/me") 35 | def read_current_user(current_user: Annotated[User, Depends(get_current_user_basic)]): 36 | return current_user 37 | 38 | 39 | @app.post("/signup") 40 | def signup(req: SignupRequest): 41 | return create_user(req.username, req.password) 42 | -------------------------------------------------------------------------------- /examples/fastapi-example-oauth2.py: -------------------------------------------------------------------------------- 1 | # This example demonstrates how use Dante to implement OAuth2 for FastAPI. 2 | # 3 | # To run this example, you need to install FastAPI: 4 | # 5 | # $ pip install fastapi[standard] 6 | # 7 | # Then, you can run the FastAPI server: 8 | # 9 | # $ cd examples/ 10 | # $ fastapi dev fastapi-example-oauth2.py 11 | # 12 | # And visit http://localhost:8000/docs to interact with the API. 13 | 14 | from __future__ import annotations 15 | 16 | from typing import Annotated 17 | 18 | from fastapi import Depends, FastAPI 19 | from fastapi_auth import ( 20 | User, 21 | create_user, 22 | get_current_user_oauth2, 23 | init, 24 | oauth2_login_flow, 25 | ) 26 | from pydantic import BaseModel 27 | 28 | from dante import Dante 29 | 30 | app = FastAPI() 31 | db = Dante("users.db", check_same_thread=False) 32 | users = init(db) 33 | 34 | 35 | class SignupRequest(BaseModel): 36 | username: str 37 | password: str 38 | 39 | 40 | @app.post("/token") 41 | async def login(response: dict = Depends(oauth2_login_flow)): 42 | return response 43 | 44 | 45 | @app.get("/me") 46 | def read_current_user(current_user: Annotated[User, Depends(get_current_user_oauth2)]): 47 | return current_user 48 | 49 | 50 | @app.post("/signup") 51 | def signup(req: SignupRequest): 52 | return create_user(req.username, req.password, include_token=True) 53 | -------------------------------------------------------------------------------- /examples/todo.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from dante import Dante 4 | 5 | db = Dante("todo.db") 6 | collection = db["todos"] 7 | 8 | 9 | def help(): 10 | print("Usage: todo.py [args...]") 11 | print("Commands:") 12 | print(" list") 13 | print(" add ") 14 | print(" remove ") 15 | print(" done ") 16 | print(" undone ") 17 | 18 | 19 | def main(): 20 | if len(sys.argv) < 2: 21 | help() 22 | sys.exit(-1) 23 | 24 | command = sys.argv[1] 25 | 26 | if command == "list": 27 | for i, todo in enumerate(collection): 28 | print(f"{i+1}. {todo['text']}{' (done)' if todo['done'] else ''}") 29 | 30 | return 31 | elif command == "help": 32 | help() 33 | return 34 | 35 | if len(sys.argv) < 3: 36 | print("Missing argument: todo") 37 | sys.exit(-1) 38 | 39 | todo = sys.argv[2] 40 | 41 | if command == "add": 42 | collection.insert({"text": todo, "done": False}) 43 | elif command == "remove": 44 | collection.delete(text=todo) 45 | elif command == "done": 46 | collection.set({"done": True}, text=todo) 47 | elif command == "undone": 48 | collection.set({"done": False}, text=todo) 49 | else: 50 | print(f"Unknown command: {command} (try 'help' to see available commands)") 51 | sys.exit(-1) 52 | 53 | 54 | main() 55 | -------------------------------------------------------------------------------- /tests/test_async_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import BaseModel 3 | 4 | 5 | class MyModel(BaseModel): 6 | a: int 7 | b: str = "foo" 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_get_model_collection(db): 12 | coll = await db[MyModel] 13 | assert coll.model == MyModel 14 | assert coll.name == "MyModel" 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_insert_find_one_model(db): 19 | coll = await db[MyModel] 20 | 21 | obj = MyModel(a=1) 22 | await coll.insert(obj) 23 | 24 | result = await coll.find_one(a=1) 25 | assert isinstance(result, MyModel) 26 | 27 | assert result.a == 1 28 | assert result.b == "foo" 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_insert_find_many_model(db): 33 | coll = await db[MyModel] 34 | 35 | obj = MyModel(a=1) 36 | await coll.insert(obj) 37 | 38 | result = await coll.find_many(a=1) 39 | assert len(result) == 1 40 | assert isinstance(result[0], MyModel) 41 | 42 | assert result[0].a == 1 43 | assert result[0].b == "foo" 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_update_model(db): 48 | coll = await db[MyModel] 49 | 50 | obj = MyModel(a=1) 51 | await coll.insert(obj) 52 | 53 | obj.b = "bar" 54 | await coll.update(obj, a=1) 55 | 56 | result = await coll.find_one(a=1) 57 | assert isinstance(result, MyModel) 58 | 59 | assert result.a == 1 60 | assert result.b == "bar" 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.9", "3.12"] 18 | os: [ubuntu-24.04, macos-latest, windows-latest] 19 | exclude: 20 | - os: windows-latest 21 | python-version: 3.9 22 | - os: macos-latest 23 | python-version: 3.9 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Set up uv 34 | if: ${{ matrix.os == 'ubuntu-24.04' || matrix.os == 'macos-latest' }} 35 | run: curl -LsSf https://astral.sh/uv/install.sh | sh 36 | 37 | - name: Set up uv 38 | if: ${{ matrix.os == 'windows-latest' }} 39 | run: irm https://astral.sh/uv/install.ps1 | iex 40 | shell: powershell 41 | 42 | - name: Install dependencies 43 | run: uv sync --extra=dev 44 | 45 | - name: Lint with ruff 46 | run: uv run ruff check --output-format github 47 | 48 | - name: Check code style with ruff 49 | run: uv run ruff format --check --diff 50 | 51 | - name: Test with pytest 52 | run: uv run pytest --cov=dante --cov-report=lcov 53 | timeout-minutes: 5 54 | 55 | - name: Build package 56 | run: uvx --from build pyproject-build --installer uv 57 | 58 | - name: Coveralls 59 | uses: coverallsapp/github-action@v2 60 | 61 | - name: Check types 62 | run: uv run mypy dante 63 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import BaseModel 3 | 4 | from dante import Dante 5 | 6 | 7 | class MyModel(BaseModel): 8 | a: int 9 | b: str = "foo" 10 | 11 | 12 | def test_get_model_collection(): 13 | db = Dante() 14 | coll = db[MyModel] 15 | assert coll.model == MyModel 16 | assert coll.name == "MyModel" 17 | 18 | 19 | def test_insert_find_one_model(): 20 | db = Dante() 21 | coll = db[MyModel] 22 | 23 | obj = MyModel(a=1) 24 | coll.insert(obj) 25 | 26 | result = coll.find_one(a=1) 27 | assert isinstance(result, MyModel) 28 | 29 | assert result.a == 1 30 | assert result.b == "foo" 31 | 32 | 33 | def test_insert_find_many_model(): 34 | db = Dante() 35 | coll = db[MyModel] 36 | 37 | obj = MyModel(a=1) 38 | coll.insert(obj) 39 | 40 | result = coll.find_many(a=1) 41 | assert len(result) == 1 42 | assert isinstance(result[0], MyModel) 43 | 44 | assert result[0].a == 1 45 | assert result[0].b == "foo" 46 | 47 | 48 | def test_update_model(): 49 | db = Dante() 50 | coll = db[MyModel] 51 | 52 | obj = MyModel(a=1) 53 | coll.insert(obj) 54 | 55 | obj.b = "bar" 56 | coll.update(obj, a=1) 57 | 58 | result = coll.find_one(a=1) 59 | assert isinstance(result, MyModel) 60 | 61 | assert result.a == 1 62 | assert result.b == "bar" 63 | 64 | 65 | def test_update_without_filter_fails(): 66 | db = Dante() 67 | coll = db[MyModel] 68 | 69 | obj = MyModel(a=1) 70 | 71 | with pytest.raises(ValueError): 72 | coll.update(obj) 73 | 74 | 75 | def test_delete_without_filter_fails(): 76 | db = Dante() 77 | coll = db[MyModel] 78 | 79 | with pytest.raises(ValueError): 80 | coll.delete() 81 | -------------------------------------------------------------------------------- /examples/fastapi-example.py: -------------------------------------------------------------------------------- 1 | # This example demonstrates how to use Dante with FastAPI. 2 | # 3 | # To run this example, you need to install FastAPI: 4 | # 5 | # $ pip install fastapi[standard] 6 | # 7 | # Then, you can run the FastAPI server: 8 | # 9 | # $ cd examples/ 10 | # $ fastapi dev fastapi-example.py 11 | # 12 | # And visit http://localhost:8000/docs to interact with the API. 13 | 14 | from __future__ import annotations 15 | 16 | from datetime import date 17 | from typing import Optional 18 | 19 | from fastapi import FastAPI, HTTPException, status 20 | from pydantic import BaseModel 21 | 22 | from dante import Dante 23 | 24 | 25 | class Book(BaseModel): 26 | isbn: str 27 | title: str 28 | authors: list[str] 29 | published_date: date 30 | summary: Optional[str] = None 31 | 32 | 33 | app = FastAPI() 34 | db = Dante("books.db", check_same_thread=False) 35 | books = db[Book] 36 | 37 | 38 | @app.get("/books/") 39 | def list_books() -> list[Book]: 40 | return books.find_many() 41 | 42 | 43 | @app.post("/books/", status_code=status.HTTP_201_CREATED) 44 | def create_book(book: Book): 45 | books.insert(book) 46 | return book 47 | 48 | 49 | @app.get("/books/{isbn}") 50 | def get_book(isbn: str): 51 | book = books.find_one(isbn=isbn) 52 | if book is None: 53 | raise HTTPException( 54 | status_code=status.HTTP_404_NOT_FOUND, 55 | detail="Book not found", 56 | ) 57 | return book 58 | 59 | 60 | @app.put("/books/{isbn}") 61 | def update_book(isbn: str, book: Book): 62 | n = books.update(book, isbn=isbn) 63 | if not n: 64 | raise HTTPException( 65 | status_code=status.HTTP_404_NOT_FOUND, 66 | detail="Book not found", 67 | ) 68 | return book 69 | 70 | 71 | @app.delete("/books/{isbn}", status_code=status.HTTP_204_NO_CONTENT) 72 | def delete_book(isbn: str): 73 | n = books.delete(isbn=isbn) 74 | if not n: 75 | raise HTTPException( 76 | status_code=status.HTTP_404_NOT_FOUND, 77 | detail="Book not found", 78 | ) 79 | return {"message": "Book deleted"} 80 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Developing Dante 2 | 3 | This document describes how to develop Dante. For the user documentation, see 4 | the [README](../README.md) and [API Reference](api.md) documentation. 5 | 6 | ## The Zen of Dante 7 | 8 | Goals: 9 | 10 | * zero-setup 11 | * sync and async 12 | * easy to use 13 | * simple API with no nasty surprises 14 | 15 | Code guidelines: 16 | 17 | * feature-parity across sync and async (with identical API where applicable) 18 | * 100% test coverage 19 | * 100% coverage with type hints 20 | * explicit is better than implicit (and, really, everything from the Zen of 21 | Python) 22 | 23 | ## Setup 24 | 25 | We recommend using [uv](https://github.com/astral-sh/uv), but you can also 26 | use other tools to build and test Dante. 27 | 28 | 1. Clone the repository: 29 | 30 | ```shell 31 | git clone git@github.com:senko/dante 32 | cd dante 33 | ``` 34 | 35 | 2. Set up the virtual environment and install the dependencies: 36 | 37 | ```shell 38 | uv sync --dev 39 | source .venv/bin/activate 40 | ``` 41 | 42 | 3. Set up git pre-commit hooks: 43 | 44 | ```shell 45 | pre-commit install 46 | ``` 47 | 48 | ## Tests 49 | 50 | Run the tests with coverage plugin: 51 | 52 | ```shell 53 | pytest --cov=dante 54 | ``` 55 | 56 | This will show the coverage summary. To build a detailed HTML report, 57 | then run: 58 | 59 | ```shell 60 | coverage html 61 | ``` 62 | 63 | The report will be saved in the `htmlcov` directory. 64 | 65 | ## Linting and formatting 66 | 67 | We use `ruff` for formatting and linting, with the default rules. To run 68 | the checks: 69 | 70 | ```shell 71 | ruff check --fix 72 | ruff format 73 | ``` 74 | 75 | ## Publishing the package 76 | 77 | To publish the package, follow these steps: 78 | 79 | 1. Run `pytest`, `ruff check`, and `ruff format` to ensure everything is in 80 | order. 81 | 2. Bump the version in `pyproject.toml`, commit the version bump, and create a tag for it. 82 | 3. Push the package to GitHub and wait for the tests to pass. 83 | 4. Build the package: `uvx --from build pyproject-build --installer uv` 84 | 5. Upload the package to PyPI: `uvx twine upload dist/*` 85 | 6. Clear the dist directory: `rm -rf dist/` 86 | 7. Create a release on GitHub with the release notes, referencing the newly created tag. -------------------------------------------------------------------------------- /tests/test_sync.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from dante.sync import Dante 6 | 7 | 8 | def test_create_dante_on_disk(tmp_path): 9 | db_path = tmp_path / "test.db" 10 | db = Dante(db_path) 11 | assert db is not None 12 | _ = db["test"] 13 | db.close() 14 | assert db_path.exists() 15 | 16 | 17 | def test_create_collection(): 18 | db = Dante() 19 | coll = db["test"] 20 | assert coll is not None 21 | x = ( 22 | db.conn.cursor() 23 | .execute("select name from sqlite_master where type = 'table'") 24 | .fetchone() 25 | ) 26 | assert x == ("test",) 27 | 28 | 29 | def test_insert_find_one(): 30 | db = Dante() 31 | coll = db["test"] 32 | 33 | data = {"a": 1, "b": 2} 34 | coll.insert(data) 35 | result = coll.find_one(a=1, b=2) 36 | assert result == data 37 | 38 | 39 | def test_insert_find_many(): 40 | db = Dante() 41 | coll = db["test"] 42 | 43 | obj1 = {"a": 1, "b": 2, "c": 3} 44 | obj2 = {"a": 1, "e": 4, "f": 5} 45 | coll.insert(obj1) 46 | coll.insert(obj2) 47 | result = coll.find_many(a=1) 48 | assert len(result) == 2 49 | 50 | 51 | def test_find_nested(): 52 | db = Dante() 53 | coll = db["test"] 54 | 55 | obj = {"a": {"b": {"c": 1}}} 56 | coll.insert(obj) 57 | result = coll.find_one(a__b__c=1) 58 | assert obj == result 59 | 60 | 61 | def test_iteration(): 62 | db = Dante() 63 | coll = db["test"] 64 | 65 | coll.insert({"a": 1, "b": 2, "c": 3}) 66 | 67 | result = [d for d in coll] 68 | assert len(result) == 1 69 | assert result[0]["a"] == 1 70 | 71 | 72 | def test_insert_datetime(): 73 | db = Dante() 74 | coll = db["test"] 75 | 76 | data = {"a": 1, "b": datetime.now()} 77 | coll.insert(data) 78 | result = coll.find_one(a=1) 79 | assert datetime.fromisoformat(result["b"]) == data["b"] 80 | 81 | 82 | def test_find_none(): 83 | db = Dante() 84 | coll = db["test"] 85 | 86 | result = coll.find_one(a=1) 87 | assert result is None 88 | 89 | 90 | def test_update(): 91 | db = Dante() 92 | coll = db["test"] 93 | 94 | coll.insert({"a": 1, "b": 2}) 95 | n = coll.update({"a": 1, "b": 3}, a=1) 96 | assert n == 1 97 | result = coll.find_one(a=1) 98 | assert result["b"] == 3 99 | 100 | 101 | def test_update_without_filter_fails(): 102 | db = Dante() 103 | coll = db["test"] 104 | with pytest.raises(ValueError): 105 | coll.update({}) 106 | 107 | 108 | def test_set(): 109 | db = Dante() 110 | coll = db["test"] 111 | coll.insert({"a": 1, "b": 2}) 112 | n = coll.set({"b": 3}, a=1) 113 | assert n == 1 114 | result = coll.find_one(a=1) 115 | assert result["b"] == 3 116 | 117 | 118 | def test_set_nested(): 119 | db = Dante() 120 | coll = db["test"] 121 | coll.insert({"a": {"b": 2}}) 122 | coll.set({"a__b": 3}, a__b=2) 123 | result = coll.find_one(a__b=3) 124 | assert result["a"]["b"] == 3 125 | 126 | 127 | def test_set_without_fields_fails(): 128 | db = Dante() 129 | coll = db["test"] 130 | with pytest.raises(ValueError): 131 | coll.set({}, a=1) 132 | 133 | 134 | def test_set_without_filter_fails(): 135 | db = Dante() 136 | coll = db["test"] 137 | with pytest.raises(ValueError): 138 | coll.set({"b": 3}) 139 | 140 | 141 | def test_delete(): 142 | db = Dante() 143 | coll = db["test"] 144 | 145 | coll.insert({"a": 1, "b": 2}) 146 | coll.insert({"a": 1, "b": 3}) 147 | n = coll.delete(a=1) 148 | assert n == 2 149 | result = coll.find_many(a=1) 150 | assert result == [] 151 | 152 | 153 | def test_delete_without_filter_fails(): 154 | db = Dante() 155 | coll = db["test"] 156 | with pytest.raises(ValueError): 157 | coll.delete() 158 | 159 | 160 | def test_clear(): 161 | db = Dante() 162 | coll = db["test"] 163 | 164 | coll.insert({"a": 1, "b": 2}) 165 | n = coll.clear() 166 | assert n == 1 167 | result = coll.find_many() 168 | assert result == [] 169 | 170 | 171 | def test_str(): 172 | d = Dante() 173 | assert str(d) == '' 174 | c = d["test"] 175 | assert str(c) == '' 176 | -------------------------------------------------------------------------------- /tests/test_async.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from datetime import datetime 3 | 4 | import pytest 5 | 6 | from dante import AsyncDante 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_create_dante_on_disk(tmp_path): 11 | db_path = tmp_path / "test.db" 12 | db = AsyncDante(db_path) 13 | assert db is not None 14 | 15 | assert not db_path.exists() 16 | 17 | coll = await db["test"] 18 | assert coll is not None 19 | assert db_path.exists() 20 | await db.close() 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_create_collection(db): 25 | coll = await db["test"] 26 | assert coll is not None 27 | q = await db.conn.execute("select name from sqlite_master where type = 'table'") 28 | x = await q.fetchone() 29 | assert x == ("test",) 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_insert_find_one(db): 34 | coll = await db["test"] 35 | 36 | data = {"a": 1, "b": 2} 37 | await coll.insert(data) 38 | result = await coll.find_one(a=1, b=2) 39 | assert result == data 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_insert_find_many(db): 44 | coll = await db["test"] 45 | 46 | obj1 = {"a": 1, "b": 2, "c": 3} 47 | obj2 = {"a": 1, "e": 4, "f": 5} 48 | await gather( 49 | coll.insert(obj1), 50 | coll.insert(obj2), 51 | ) 52 | result = await coll.find_many(a=1) 53 | assert len(result) == 2 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_insert_datetime(db): 58 | coll = await db["test"] 59 | 60 | data = {"a": 1, "b": datetime.now()} 61 | await coll.insert(data) 62 | result = await coll.find_one(a=1) 63 | assert datetime.fromisoformat(result["b"]) == data["b"] 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_find_none(db): 68 | coll = await db["test"] 69 | 70 | result = await coll.find_one(a=1) 71 | assert result is None 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_iteration(db): 76 | coll = await db["test"] 77 | 78 | await coll.insert({"a": 1}) 79 | 80 | count = 0 81 | async for data in coll: 82 | count += 1 83 | assert data["a"] == 1 84 | 85 | assert count == 1 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_update(db): 90 | coll = await db["test"] 91 | 92 | await coll.insert({"a": 1, "b": 2}) 93 | n = await coll.update({"a": 1, "b": 3}, a=1) 94 | assert n == 1 95 | result = await coll.find_one(a=1) 96 | assert result["b"] == 3 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_update_without_filter_fails(db): 101 | coll = await db["test"] 102 | 103 | await coll.insert({"a": 1, "b": 2}) 104 | with pytest.raises(ValueError): 105 | await coll.update({}) 106 | 107 | 108 | @pytest.mark.asyncio 109 | async def test_set(db): 110 | coll = await db["test"] 111 | await coll.insert({"a": 1, "b": 2}) 112 | n = await coll.set({"b": 3}, a=1) 113 | assert n == 1 114 | result = await coll.find_one(a=1) 115 | assert result["b"] == 3 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_set_without_fields_fails(db): 120 | coll = await db["test"] 121 | with pytest.raises(ValueError): 122 | await coll.set({}, a=1) 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_set_without_filter_fails(db): 127 | coll = await db["test"] 128 | with pytest.raises(ValueError): 129 | await coll.set({"a": 1}) 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_delete(db): 134 | coll = await db["test"] 135 | 136 | await gather( 137 | coll.insert({"a": 1, "b": 2}), 138 | coll.insert({"a": 1, "b": 3}), 139 | ) 140 | n = await coll.delete(a=1) 141 | assert n == 2 142 | result = await coll.find_many(a=1) 143 | assert result == [] 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_delete_without_filter_fails(db): 148 | coll = await db["test"] 149 | 150 | with pytest.raises(ValueError): 151 | await coll.delete() 152 | 153 | 154 | @pytest.mark.asyncio 155 | async def test_clear(db): 156 | coll = await db["test"] 157 | 158 | await coll.insert({"a": 1, "b": 2}) 159 | n = await coll.clear() 160 | assert n == 1 161 | result = await coll.find_many() 162 | assert result == [] 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dante, a document store for Python backed by SQLite 2 | 3 | [![Build](https://github.com/senko/dante/actions/workflows/ci.yml/badge.svg)](https://github.com/senko/dante/actions/workflows/ci.yml) 4 | [![Coverage](https://coveralls.io/repos/github/senko/dante/badge.svg?branch=main)](https://coveralls.io/github/senko/dante?branch=main) 5 | [![PyPI](https://img.shields.io/pypi/v/dante-db)](https://pypi.org/project/dante-db/) 6 | 7 | Dante is zero-setup, easy to use document store (NoSQL database) for Python. 8 | It's ideal for exploratory programming, prototyping, internal tools and 9 | small, simple projects. 10 | 11 | Dante can store Python dictionaries or Pydantic models, supports both 12 | sync and async mode, and is based on SQLite. 13 | 14 | Dante *does not* support SQL, relations, ACID, aggregation, replication and is 15 | emphatically not web-scale. If you need those features, you should choose 16 | another database or ORM engine. 17 | 18 | * [Quickstart](#quickstart) 19 | * [API Reference](docs/api.md) 20 | * [Examples](#examples) 21 | 22 | ## Quickstart 23 | 24 | 1. Install via PyPI: 25 | 26 | ```shell 27 | pip install dante-db 28 | ``` 29 | 30 | 2. Use it with Python dictionaries ([example](examples/hello.py)): 31 | 32 | ```python 33 | from dante import Dante 34 | 35 | # Create 'mydatabase.db' in current directory and open it 36 | # (you can omit the database name to create a temporary in-memory database.) 37 | db = Dante("mydatabase.db") 38 | 39 | # Use 'mycollection' collection (also known as a "table") 40 | collection = db["mycollection"] 41 | 42 | # Insert a dictionary to the database 43 | data = {"name": "Dante", "text": "Hello World!"} 44 | collection.insert(data) 45 | 46 | # Find a dictionary with the specified attribute(s) 47 | result = collection.find_one(name="Dante") 48 | print(result["text"]) 49 | 50 | new_data = {"name": "Virgil", "text": "Hello World!"} 51 | collection.update(new_data, name="Dante") 52 | ``` 53 | 54 | Under the hood, Dante stores each dictionary in a JSON-encoded TEXT column 55 | in a table (one per collection) in the SQLite database. 56 | 57 | ## Use with Pydantic 58 | 59 | Dante works great with Pydantic. 60 | 61 | Using the same API as with the plain Python objects, you can insert, 62 | query and delete Pydantic models ([example](examples/hello-pydantic.py)): 63 | 64 | ```python 65 | from dante import Dante 66 | from pydantic import BaseModel 67 | 68 | class Message(BaseModel): 69 | name: str 70 | text: str 71 | 72 | # Open the database and get the collection for messages 73 | db = Dante("mydatabase.db") 74 | collection = db[Message] 75 | 76 | # Insert a model to the database 77 | obj = Message(name="Dante", text="Hello world!") 78 | collection.insert(obj) 79 | 80 | # Find a model with the specified attribute(s) 81 | result = collection.find_one(name="Dante") 82 | print(result.text) 83 | 84 | # Find a model in the collection with the attribute name=Dante 85 | # and update (overwrite) it with the new model data 86 | result.name = "Virgil" 87 | collection.update(result, name="Dante") 88 | ``` 89 | 90 | ## Async Dante 91 | 92 | Dante supports async usage with the identical API, both for plain Python 93 | objects and Pydantic models ([example](examples/hello-async.py)): 94 | 95 | ```python 96 | from asyncio import run 97 | from dante import AsyncDante 98 | 99 | async def main(): 100 | db = AsyncDante("mydatabase.db") 101 | collection = await db["mycollection"] 102 | 103 | data = {"name": "Dante", "text": "Hello World!"} 104 | await collection.insert(data) 105 | 106 | result = await collection.find_one(name="Dante") 107 | print(result["text"]) 108 | 109 | new_data = {"name": "Virgil", "text": "Hello World!"} 110 | await collection.update(new_data, name="Dante") 111 | 112 | await db.close() 113 | 114 | run(main()) 115 | ``` 116 | 117 | ## Examples 118 | 119 | Check out the command-line [ToDo app](examples/todo.py), 120 | a simple [FastAPI CRUD app](examples/fastapi-example.py), 121 | and the other examples in the [examples](examples/) directory. 122 | 123 | ## Development 124 | 125 | Detailed guide on how to develop, test and publish Dante is available in the 126 | [Developer documentation](docs/development.md). 127 | 128 | 129 | ## License (MIT) 130 | 131 | Copyright (c) 2024. Senko Rasic 132 | 133 | This software is licensed under the MIT license. See the 134 | [LICENSE](LICENSE) file for details. 135 | -------------------------------------------------------------------------------- /dante/asyncdante.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, AsyncGenerator 4 | 5 | import aiosqlite 6 | from pydantic import BaseModel 7 | 8 | from .base import BaseCollection, BaseDante 9 | 10 | 11 | class Dante(BaseDante): 12 | async def get_connection(self) -> aiosqlite.Connection: 13 | if not self.conn: 14 | self.conn = await aiosqlite.connect( 15 | self.db_name, 16 | check_same_thread=self.check_same_thread, 17 | ) 18 | 19 | return self.conn 20 | 21 | async def collection( 22 | self, 23 | name: str, 24 | model: BaseModel | None = None, 25 | ) -> "Collection": 26 | conn = await self.get_connection() 27 | await conn.execute(f"CREATE TABLE IF NOT EXISTS {name} (data TEXT)") 28 | await conn.commit() 29 | return Collection(name, self, model) 30 | 31 | async def commit(self): 32 | if self.conn: 33 | await self.conn.commit() 34 | 35 | async def _maybe_commit(self): 36 | if self.auto_commit and self.conn: 37 | await self.commit() 38 | 39 | async def close(self): 40 | if self.conn: 41 | await self.conn.close() 42 | self.conn = None 43 | 44 | 45 | class Collection(BaseCollection): 46 | """ 47 | Asynchronous Dante collection. 48 | 49 | If the pydantic model class is specified, the data is automatically 50 | serialized/deserialized. 51 | 52 | :param name: Name of the collection 53 | :param db: Dante instance 54 | :param model: Pydantic model class (if using with Pydantic) 55 | """ 56 | 57 | async def insert(self, data: dict[str, Any] | BaseModel): 58 | conn: aiosqlite.Connection = await self.db.get_connection() 59 | await conn.execute( 60 | f"INSERT INTO {self.name} (data) VALUES (?)", (self._to_json(data),) 61 | ) 62 | await self.db._maybe_commit() 63 | 64 | async def find_many( 65 | _self, 66 | _limit: int | None = None, 67 | /, 68 | **kwargs: Any, 69 | ) -> list[dict[str, Any] | BaseModel]: 70 | query, values = _self._build_query(_limit, **kwargs) 71 | 72 | conn: aiosqlite.Connection = await _self.db.get_connection() 73 | cursor = await conn.cursor() 74 | await cursor.execute(f"SELECT data FROM {_self.name}{query}", values) 75 | rows = await cursor.fetchall() 76 | 77 | return [_self._from_json(row[0]) for row in rows] 78 | 79 | async def find_one(_self, **kwargs: Any) -> dict | BaseModel | None: 80 | results = await _self.find_many(1, **kwargs) 81 | return results[0] if len(results) > 0 else None 82 | 83 | async def update(_self, _data: dict[str, Any] | BaseModel, /, **kwargs: Any) -> int: 84 | if not kwargs: 85 | raise ValueError("You must provide a filter to update") 86 | 87 | query, values = _self._build_query(None, **kwargs) 88 | 89 | conn: aiosqlite.Connection = await _self.db.get_connection() 90 | cursor = await conn.execute( 91 | f"UPDATE {_self.name} SET data = ?{query}", 92 | (_self._to_json(_data), *values), 93 | ) 94 | updated_rows = cursor.rowcount 95 | await _self.db._maybe_commit() 96 | return updated_rows 97 | 98 | async def set(_self, _fields: dict[str, Any], **kwargs: Any) -> int: 99 | if not _fields: 100 | raise ValueError("You must provide fields to set") 101 | 102 | if not kwargs: 103 | raise ValueError("You must provide a filter to update") 104 | 105 | set_clause, clause_values = _self._build_set_clause(**_fields) 106 | query, query_values = _self._build_query(None, **kwargs) 107 | 108 | conn: aiosqlite.Connection = await _self.db.get_connection() 109 | cursor = await conn.execute( 110 | f"UPDATE {_self.name} {set_clause} {query}", 111 | *[clause_values + query_values], 112 | ) 113 | updated_rows = cursor.rowcount 114 | await _self.db._maybe_commit() 115 | return updated_rows 116 | 117 | async def delete(_self, /, **kwargs: Any) -> int: 118 | if not kwargs: 119 | raise ValueError("You must provide a filter to delete") 120 | 121 | query, values = _self._build_query(None, **kwargs) 122 | 123 | conn: aiosqlite.Connection = await _self.db.get_connection() 124 | cursor = await conn.execute(f"DELETE FROM {_self.name}{query}", values) 125 | deleted_rows = cursor.rowcount 126 | await _self.db._maybe_commit() 127 | return deleted_rows 128 | 129 | async def clear(self) -> int: 130 | conn: aiosqlite.Connection = await self.db.get_connection() 131 | cursor = await conn.execute(f"DELETE FROM {self.name}") 132 | deleted_rows = cursor.rowcount 133 | await self.db._maybe_commit() 134 | return deleted_rows 135 | 136 | async def __aiter__(self) -> AsyncGenerator[dict | BaseModel]: 137 | """ 138 | Asynchronously iterate over the documents in the collection. 139 | """ 140 | results = await self.find_many() 141 | for r in results: 142 | yield r 143 | -------------------------------------------------------------------------------- /dante/sync.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | from typing import Any, Iterable 5 | 6 | 7 | from .base import BaseCollection, BaseDante, TModel 8 | 9 | 10 | class Dante(BaseDante): 11 | """ 12 | Dante, a simple synchronous database wrapper for SQLite. 13 | 14 | :param db_name: Name of the database, defaults to in-memory database 15 | :param auto_commit: Whether to automatically commit transactions, defaults to True 16 | 17 | Usage: 18 | 19 | >>> from dante import Dante 20 | >>> db = Dante() 21 | >>> coll = db.collection("test") 22 | >>> coll.insert({"person": "Jane", "message": "Hello World!"}) 23 | >>> result = coll.find_one(person="Jane") 24 | >>> result["message"] = "Goodbye World!" 25 | >>> coll.update_one(result, person="Jane") 26 | >>> coll.delete_one(person="Jane") 27 | """ 28 | 29 | def get_connection(self) -> sqlite3.Connection: 30 | if not self.conn: 31 | self.conn = sqlite3.connect( 32 | self.db_name, 33 | check_same_thread=self.check_same_thread, 34 | ) 35 | return self.conn 36 | 37 | def collection(self, name: str, model: TModel | None = None) -> Collection: 38 | conn = self.get_connection() 39 | conn.execute(f"CREATE TABLE IF NOT EXISTS {name} (data TEXT)") 40 | conn.commit() 41 | return Collection(name, self, model) 42 | 43 | def commit(self): 44 | if self.conn: 45 | self.conn.commit() 46 | 47 | def _maybe_commit(self): 48 | if self.auto_commit and self.conn: 49 | self.commit() 50 | 51 | def close(self): 52 | if self.conn: 53 | self.conn.close() 54 | self.conn = None 55 | 56 | 57 | class Collection(BaseCollection): 58 | """ 59 | Synchronous Dante collection. 60 | 61 | If the pydantic model class is specified, the data is automatically 62 | serialized/deserialized. 63 | 64 | :param name: Name of the collection 65 | :param db: Dante instance 66 | :param model: Pydantic model class (if using with Pydantic) 67 | """ 68 | 69 | @property 70 | def conn(self) -> sqlite3.Connection: 71 | return self.db.get_connection() 72 | 73 | def insert(self, data: dict[str, Any] | TModel): 74 | cursor = self.conn.cursor() 75 | cursor.execute( 76 | f"INSERT INTO {self.name} (data) VALUES (?)", (self._to_json(data),) 77 | ) 78 | self.db._maybe_commit() 79 | 80 | def find_many( 81 | _self, 82 | _limit: int | None = None, 83 | /, 84 | **kwargs: Any, 85 | ) -> list[dict | TModel]: 86 | query, values = _self._build_query(_limit, **kwargs) 87 | 88 | cursor = _self.conn.execute(f"SELECT data FROM {_self.name}{query}", values) 89 | rows = cursor.fetchall() 90 | 91 | return [_self._from_json(row[0]) for row in rows] 92 | 93 | def find_one(_self, **kwargs: Any) -> dict | TModel | None: 94 | results = _self.find_many(1, **kwargs) 95 | return results[0] if len(results) > 0 else None 96 | 97 | def update(_self, _data: dict[str, Any] | TModel, /, **kwargs: Any) -> int: 98 | if not kwargs: 99 | raise ValueError("You must provide a filter to update") 100 | 101 | query, values = _self._build_query(None, **kwargs) 102 | 103 | cursor = _self.conn.cursor() 104 | cursor.execute( 105 | f"UPDATE {_self.name} SET data = ? {query}", 106 | (_self._to_json(_data), *values), 107 | ) 108 | updated_rows = cursor.rowcount 109 | _self.db._maybe_commit() 110 | return updated_rows 111 | 112 | def set(_self, _fields: dict[str, Any], **kwargs: Any) -> int: 113 | if not _fields: 114 | raise ValueError("You must provide fields to set") 115 | 116 | if not kwargs: 117 | raise ValueError("You must provide a filter to update") 118 | 119 | set_clause, clause_values = _self._build_set_clause(**_fields) 120 | query, query_values = _self._build_query(None, **kwargs) 121 | 122 | cursor = _self.conn.execute( 123 | f"UPDATE {_self.name} {set_clause} {query}", 124 | *[clause_values + query_values], 125 | ) 126 | updated_rows = cursor.rowcount 127 | _self.db._maybe_commit() 128 | return updated_rows 129 | 130 | def delete(_self, /, **kwargs: Any) -> int: 131 | if not kwargs: 132 | raise ValueError("You must provide a filter to delete") 133 | 134 | query, values = _self._build_query(None, **kwargs) 135 | 136 | cursor = _self.conn.execute(f"DELETE FROM {_self.name}{query}", values) 137 | deleted_rows = cursor.rowcount 138 | _self.db._maybe_commit() 139 | return deleted_rows 140 | 141 | def clear(self) -> int: 142 | cursor = self.conn.execute(f"DELETE FROM {self.name}") 143 | deleted_rows = cursor.rowcount 144 | self.db._maybe_commit() 145 | return deleted_rows 146 | 147 | def __iter__(self) -> Iterable[dict[str, Any] | TModel]: 148 | """ 149 | Iterate over the documents in the collection. 150 | """ 151 | return iter(self.find_many()) 152 | -------------------------------------------------------------------------------- /examples/fastapi_auth.py: -------------------------------------------------------------------------------- 1 | # Dante-backed user authentication for FastAPI 2 | # 3 | # Provides a simple user authentication system with a simple User 4 | # model, and implements basic and OAuth2 password flows. Copy and 5 | # adapt as needed for your project. 6 | # 7 | # For usage, check fastapi-example-basic-auth.py 8 | # and fastapi-example-oauth2.py. 9 | 10 | from __future__ import annotations 11 | 12 | from datetime import datetime 13 | from hashlib import sha512 14 | from typing import Annotated, Optional 15 | from uuid import uuid4 16 | 17 | from fastapi import Depends, HTTPException, status 18 | from fastapi.security import ( 19 | HTTPBasic, 20 | HTTPBasicCredentials, 21 | OAuth2PasswordBearer, 22 | OAuth2PasswordRequestForm, 23 | ) 24 | from passlib.hash import pbkdf2_sha512 25 | from pydantic import BaseModel, Field 26 | 27 | from dante.sync import Collection, Dante 28 | 29 | users: Collection 30 | 31 | 32 | class User(BaseModel): 33 | """ " 34 | User model for authentication. 35 | 36 | Password is stored hashed (use `User.set_password()`) and is never 37 | serialized back to the client. 38 | """ 39 | 40 | username: str 41 | password_hash: str = "" 42 | created_at: datetime 43 | is_active: bool = True 44 | token: Optional[str] = None 45 | 46 | def set_password(self, password: str): 47 | """ 48 | Set the user's password. 49 | 50 | Note that this only updates the `password_hash` field on 51 | the object, and doesn't actually save/update the object in the 52 | database. 53 | 54 | :param password: Password to hash and store 55 | """ 56 | self.password_hash = hash_pwd(password) 57 | 58 | 59 | class CurrentUser(User): 60 | """ 61 | User model for the current user, suitable to return to the API client. 62 | 63 | This excludes the password hash field so it's not included 64 | in the response to the client. Note that you don't want to save this 65 | to the database, otherwise you'll reset (remove) the user's password. 66 | """ 67 | 68 | password_hash: str = Field("", exclude=True) 69 | 70 | 71 | # Support for HTTP Basic auth with username/password in Authorization header 72 | basic_security = HTTPBasic() 73 | # Support for OAuth2 password flow with bearer token in Authorization header 74 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 75 | 76 | 77 | def init(db: Dante) -> Collection: 78 | """ 79 | Initialize the Users collection in the database. 80 | 81 | :param db: Dante instance 82 | :return: Users collection 83 | """ 84 | global users 85 | users = db[User] 86 | return users 87 | 88 | 89 | def hash_pwd(password: str) -> str: 90 | """ 91 | Hash a password using PBKDF2 with SHA-512. 92 | 93 | Internal method, should only be used in fastapi_auth module. 94 | 95 | :param password: Password to hash 96 | :return: Hashed password 97 | """ 98 | return pbkdf2_sha512.hash(password) 99 | 100 | 101 | def verify_pwd(password: str, hash: str) -> bool: 102 | """ 103 | Verify a password against a hash using PBKDF2 with SHA-512. 104 | 105 | Internal method, should only be used in fastapi_auth module. 106 | 107 | :param password: Password to verify 108 | :param hash: Hashed password 109 | :return: True if the password matches the hash, False otherwise 110 | """ 111 | return pbkdf2_sha512.verify(password, hash) 112 | 113 | 114 | def create_user(username: str, password: str, include_token=False) -> CurrentUser: 115 | """ 116 | Create a new user with the given username and password. 117 | 118 | Caveat: this simple implementation is vulnerable to race conditions 119 | in which many users with the same username are created at the 120 | same time. A full implementation would use a database transaction 121 | to ensure check and insert are atomic. 122 | 123 | :param username: Username 124 | :param password: Password 125 | :param include_token: Whether to include a token in the response 126 | :return: Newly created user 127 | """ 128 | if users.find_one(username=username): 129 | raise ValueError("User already exists") 130 | user = User(username=username, created_at=datetime.now()) 131 | user.set_password(password) 132 | if include_token: 133 | user.token = sha512(uuid4().bytes).hexdigest() 134 | users.insert(user) 135 | return CurrentUser(**user.model_dump()) 136 | 137 | 138 | def get_current_user_basic( 139 | credentials: HTTPBasicCredentials = Depends(basic_security), 140 | ) -> CurrentUser: 141 | """ 142 | Get current used assuming Basic HTTP auth. 143 | 144 | If there's no current user, raises a 401 Unauthorized exception. 145 | 146 | :param credentials: HTTP Basic credentials dependency from FastAPI 147 | :return: The current user 148 | """ 149 | user = users.find_one(username=credentials.username) 150 | if user is None or not verify_pwd(credentials.password, user.password_hash): 151 | raise HTTPException( 152 | status_code=status.HTTP_401_UNAUTHORIZED, 153 | detail="Invalid credentials", 154 | ) 155 | return CurrentUser(**user.model_dump()) 156 | 157 | 158 | def get_current_user_oauth2( 159 | token: Annotated[str, Depends(oauth2_scheme)], 160 | ) -> CurrentUser: 161 | """ 162 | Get current user assuming OAuth2 bearer token. 163 | 164 | If there's no current user, raises a 401 Unauthorized exception. 165 | 166 | :param token: OAuth2 bearer token dependency from FastAPI 167 | :return: The current user 168 | """ 169 | if token is None: 170 | raise HTTPException( 171 | status_code=status.HTTP_401_UNAUTHORIZED, 172 | detail="Invalid token", 173 | ) 174 | user = users.find_one(token=token) 175 | if user is None: 176 | raise HTTPException( 177 | status_code=status.HTTP_401_UNAUTHORIZED, 178 | detail="Invalid token", 179 | ) 180 | # We don't want the client to see even the hashed password of the user. 181 | # Note this means that if you just update the user object, it'll set 182 | # the password to an empty string, effectively locking out the user. 183 | return CurrentUser(**user.model_dump()) 184 | 185 | 186 | def oauth2_login_flow( 187 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()], 188 | ) -> dict: 189 | """ 190 | Implementation of the OAuth2 password flow. 191 | 192 | If the user is not found or the password is incorrect, raises a 401 193 | Unauthorized exception. 194 | 195 | :param form_data: OAuth2 password request form from FastAPI 196 | :return: OAuth2 access token 197 | """ 198 | user = users.find_one(username=form_data.username) 199 | if user is None or not verify_pwd(form_data.password, user.password_hash): 200 | raise HTTPException( 201 | status_code=status.HTTP_401_UNAUTHORIZED, 202 | detail="Incorrect username or password", 203 | ) 204 | user.token = sha512(uuid4().bytes).hexdigest() 205 | users.update(user, username=user.username) 206 | return {"access_token": user.token, "token_type": "bearer"} 207 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | Dante supports both sync and async usage. The API is identical for both modes, 4 | with the exception of the `AsyncDante` class and the `await` keyword for 5 | async operations. 6 | 7 | Note: Dante may export additional API elements that are not documented here. These are considered internal and may change without notice. 8 | 9 | ## Quick Reference 10 | 11 | - [Dante](#dante) - represents a database 12 | - [Dante()](#constructor) - open the database 13 | - [Dante.commit()](#commit) - commit changes (use if `auto_commit=False`) 14 | - [Dante.close()](#close) - close the database 15 | - [Collection](#collection) - represents a collection of documents in a database 16 | - [Plain Python objects](#plain-python-objects) - use with Python dictionaries 17 | - [Pydantic models](#pydantic-models) - use with Pydantic model 18 | - [Collection.insert()](#insert) - insert a document 19 | - [Collection.find_one()](#find) - find a document 20 | - [Collection.find_many()](#find) - find multiple documents 21 | - [Collection.update()](#update) - update matching document(s) 22 | - [Collection.set()](#set) - update specific fields in matching document(s) 23 | - [Collection.delete()](#delete) - delete matching document(s) 24 | - [Collection.clear()](#clear) - delete all documents 25 | 26 | 27 | ## `Dante` 28 | 29 | The main classes for sync usage. A Dante instance represents a database. 30 | 31 | ### Constructor 32 | 33 | `Dante(db_name: str, auto_commit: bool, check_same_thread: bool)` opens the database at the specified path, creating it if it doesn't already exist. 34 | 35 | If the `auto_commit` parameter is `True` (the default), the database will automatically commit changes after each operation. Otherwise, you need to call `commit()` manually. 36 | 37 | If the `check_same_thread` parameter is `True` (the default), the database will only be accessible from the thread that created it (see [SQLite docs](https://docs.python.org/3/library/sqlite3.html#sqlite3.connect) for more info). If you want to access the same database connection from multiple threads, set this parameter to `False`. This is useful for frameworks like FastAPI that run in multiple threads. 38 | 39 | If you omit the database name, Dante will create an in-memory database that will be lost when the program exits. 40 | 41 | Sync example: 42 | 43 | ```python 44 | from dante import Dante 45 | 46 | db = Dante("mydatabase.db") 47 | ``` 48 | 49 | Async example: 50 | 51 | ```python 52 | from dante import AsyncDante as Dante 53 | 54 | db = Dante("mydatabase.db") 55 | ``` 56 | 57 | ### Commit 58 | 59 | Commits the changes to the database. If you set `auto_commit=False` in the constructor, you need to call this method manually. 60 | 61 | Sync: 62 | 63 | ```python 64 | db.commit() 65 | ``` 66 | 67 | Async: 68 | 69 | ```python 70 | await db.commit() 71 | ``` 72 | 73 | 74 | ### Close 75 | 76 | Closes the database connection. You only need to call this method if you want to close the database before the program exits, as it will be closed automatically at exit. 77 | 78 | Sync: 79 | 80 | ```python 81 | db.close() 82 | ``` 83 | 84 | Async: 85 | 86 | ```python 87 | await db.close() 88 | ``` 89 | 90 | ## `Collection` 91 | 92 | A collection of documents in the database, corresponding to a database table. 93 | Each documents is a Python dict or a Pydantic model. 94 | 95 | To get a collection from the database, index it with the collection name or 96 | the Pydantic model class. 97 | 98 | ### Plain Python objects 99 | 100 | When the collection is named using a string, it will work with (accept and 101 | return) Python dictionaries, which may be nested and contain any 102 | JSON-serializable data types and date/datetime objects. 103 | 104 | Sync: 105 | 106 | ```python 107 | collection = db["mycollection"] 108 | ``` 109 | 110 | Async: 111 | 112 | ```python 113 | collection = await db["mycollection"] 114 | ``` 115 | 116 | ### Pydantic models 117 | 118 | When the collection is named using a Pydantic model class, it will work with 119 | instances of that model. Serialization/deserializion is handled by Pydantic. 120 | 121 | Sync: 122 | 123 | ```python 124 | collection = db[MyPydanticModel] 125 | ``` 126 | 127 | Async: 128 | 129 | ```python 130 | collection = await db[MyPydanticModel] 131 | ``` 132 | 133 | ### Insert 134 | 135 | Inserts a document into the collection. 136 | 137 | Sync with Python dict: 138 | 139 | ```python 140 | collection.insert({"name": "Dante", "text": "Hello world!"}) 141 | ``` 142 | 143 | Sync with Pydantic model: 144 | 145 | ```python 146 | obj = MyPydanticModel(name="Dante", text="Hello world!") 147 | collection.insert(obj) 148 | ``` 149 | 150 | Async with Python dict: 151 | 152 | ```python 153 | await collection.insert({"name": "Dante", "text": "Hello world!"}) 154 | ``` 155 | 156 | Async with Pydantic model: 157 | 158 | ```python 159 | obj = MyPydanticModel(name="Dante", text="Hello world!") 160 | await collection.insert(obj) 161 | ``` 162 | 163 | ### Find 164 | 165 | Finds documents in the collection. There are two variants of the `find` method: 166 | 167 | - `find_many` returns a list of documents that match the specified criteria. 168 | - `find_one` returns a single document that matches the specified criteria, or None if no document matches. 169 | 170 | Sync: 171 | 172 | ```python 173 | result = collection.find_one(name="Dante") 174 | ``` 175 | 176 | Async: 177 | 178 | ```python 179 | result = await collection.find_one(name="Dante") 180 | ``` 181 | 182 | You may specify the limit as the first (positional) argument to `find_many`: 183 | 184 | ```python 185 | results = collection.find_many(10, name="Dante") 186 | ``` 187 | 188 | Note that Dante currently does not support odering, so limit is of limited use. 189 | 190 | Both `find_one` and `find_many` accept keyword arguments for the search criteria. The criteria are matched against the document fields, and the document is returned if all criteria match. If no criteria are specified, all documents are returned. 191 | 192 | You can also match against nested fields: 193 | 194 | ```python 195 | result = collection.find_one(nested__field="value") 196 | ``` 197 | 198 | ### Update 199 | 200 | Updates matching document(s) in the collection with the specified document. 201 | Note that this will overwrite the existing document(s) with the new data. To 202 | update only specific field(s), use the `set()` method instead. 203 | 204 | The function returns the number of documents updated. 205 | 206 | Sync with Python dict: 207 | 208 | ```python 209 | collection.update({"name": "Virgil"}, name="Dante") 210 | ``` 211 | 212 | Sync with Pydantic model: 213 | 214 | ```python 215 | obj = MyPydanticModel(name="Virgil", text="Hello world!") 216 | collection.update(obj, name="Dante") 217 | ``` 218 | 219 | Async with Python dict: 220 | 221 | ```python 222 | await collection.update({"name": "Virgil"}, name="Dante") 223 | ``` 224 | 225 | Async with Pydantic model: 226 | 227 | ```python 228 | obj = MyPydanticModel(name="Virgil", text="Hello world!") 229 | await collection.update(obj, name="Dante") 230 | ``` 231 | 232 | The documents are matches using the same criteria as in `find_one` and `find_many`. 233 | Note that multiple documents may be updated if the criteria match multiple documents. 234 | 235 | ### Set 236 | 237 | Updates fields in matching document(s). This method is useful when you want to update only specific fields. The first argument should be a dictionary with the fields to 238 | update. Note that you can't use Pydantic models with this method, and you can't 239 | delete (remove) fields with this method. For that, use `update()` method instead. 240 | 241 | The function returns the number of documents updated. 242 | 243 | Sync: 244 | 245 | ```python 246 | collection.set({"name": "Virgil", "text": "Goodbye!"}, name="Dante") 247 | ``` 248 | 249 | Async: 250 | 251 | ```python 252 | await collection.set({"name": "Virgil", "text": "Goodbye!"}, name="Dante") 253 | ``` 254 | 255 | The documents are matches using the same criteria as in `find_one` and `find_many`. 256 | Note that multiple documents may be updated if the criteria match multiple documents. 257 | 258 | ### Delete 259 | 260 | Deletes matching document(s) from the collection. 261 | 262 | Returns the number of documents deleted. 263 | 264 | Sync: 265 | 266 | ```python 267 | collection.delete(name="Dante") 268 | ``` 269 | 270 | Async: 271 | 272 | ```python 273 | await collection.delete(name="Dante") 274 | ``` 275 | 276 | The documents are matches using the same criteria as in `find_one` and `find_many`. 277 | Note that multiple documents may be deleted if the criteria match multiple documents. 278 | 279 | ### Clear 280 | 281 | Deletes all documents from the collection. 282 | 283 | Returns the number of documents deleted. 284 | -------------------------------------------------------------------------------- /dante/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from abc import ABC, abstractmethod 5 | from datetime import date, datetime 6 | from typing import Any, TypeVar 7 | 8 | from pydantic import BaseModel 9 | 10 | TModel = TypeVar("TModel", bound=BaseModel) 11 | 12 | 13 | class DanteEncoder(json.JSONEncoder): 14 | """ 15 | Custom JSON encoder for handling datetime and date objects. 16 | 17 | This encoder extends the default JSON encoder to properly serialize 18 | datetime and date objects by converting them to ISO format strings. 19 | """ 20 | 21 | def default(self, obj: Any) -> str | Any: 22 | """ 23 | Encode datetime and date objects as ISO format strings. 24 | 25 | :param obj: The object to be encoded. 26 | 27 | :returns: JSON-serializable representation of obj 28 | """ 29 | if isinstance(obj, datetime) or isinstance(obj, date): 30 | return obj.isoformat() 31 | return super().default(obj) # pragma: no cover 32 | 33 | 34 | class BaseDante(ABC): 35 | """ 36 | Base class for Dante database operations. 37 | 38 | :param db_name: Name of the database, defaults to in-memory database 39 | :param auto_commit: Whether to automatically commit transactions, defaults to True 40 | :param check_same_thread: Whether to check if the same thread is used, defaults to True 41 | """ 42 | 43 | MEMORY = ":memory:" 44 | 45 | def __init__( 46 | self, 47 | db_name: str = MEMORY, 48 | auto_commit: bool = True, 49 | check_same_thread: bool = True, 50 | ): 51 | """ 52 | Initialize the Dante instance. 53 | 54 | :param db_name: Name of the database, defaults to in-memory database 55 | :param auto_commit: Whether to automatically commit transactions, defaults to True 56 | :param check_same_thread: Whether to check if the same thread is used, defaults to True 57 | 58 | SQLite by default forbids using the same database connection from multiple threads, 59 | but in some cases (eg. FastAPI) it may be useful to allow this behavior. To allow 60 | this, set `check_same_thread` to False. 61 | 62 | """ 63 | self.db_name = db_name 64 | self.conn: Any | None = None 65 | self.auto_commit = auto_commit 66 | self.check_same_thread = check_same_thread 67 | 68 | def __str__(self) -> str: 69 | """ 70 | Return a string representation of the Dante instance. 71 | 72 | :return: String representation of the instance 73 | """ 74 | return f'<{self.__class__.__name__}("{self.db_name}")>' 75 | 76 | @abstractmethod 77 | def get_connection(self): 78 | """ 79 | Get a connection to the database. 80 | 81 | :return: Database connection object 82 | """ 83 | 84 | @abstractmethod 85 | def collection(self, name: str, model: TModel | None = None): 86 | """ 87 | Get a collection from the database. 88 | 89 | :param name: Name of the collection 90 | :param model: Pydantic model class (if using with Pydantic) 91 | 92 | :return: Collection instance 93 | """ 94 | 95 | def __getitem__(self, name: str | TModel): 96 | """ 97 | Get a collection using dictionary-like syntax. 98 | 99 | :param name: Name of the collection or a Pydantic model class 100 | :return: Collection instance 101 | """ 102 | if isinstance(name, str): 103 | return self.collection(name) 104 | elif isinstance(name, type) and issubclass(name, BaseModel): 105 | return self.collection(name.__name__, name) 106 | else: 107 | raise TypeError( 108 | "Key must be string or Pydantic model class" 109 | ) # pragma: no cover 110 | 111 | @abstractmethod 112 | def _maybe_commit(self): 113 | """ 114 | Commit the current transaction if auto-commit is enabled. 115 | """ 116 | 117 | @abstractmethod 118 | def commit(self): 119 | """ 120 | Commit the current transaction (if auto-commit is disabled). 121 | 122 | This method is a no-op if auto-commit is enabled. 123 | """ 124 | 125 | @abstractmethod 126 | def close(self): 127 | """ 128 | Close the database connection. 129 | 130 | This method should be called when the database is no longer needed. 131 | """ 132 | 133 | 134 | class BaseCollection(ABC): 135 | """ 136 | Base class for collection operations in the Dante database. 137 | 138 | This class provides methods for interacting with collections in the database, 139 | including serialization and deserialization of data, and query building for 140 | find, update and delete methods. 141 | 142 | :param name: Name of the collection 143 | :param db: Dante instance representing the database 144 | :param model: Optional Pydantic model class for data validation and serialization 145 | """ 146 | 147 | def __init__(self, name: str, db: BaseDante, model: TModel | None = None): 148 | """ 149 | Initialize the BaseCollection instance. 150 | 151 | :param name: Name of the collection 152 | :param db: BaseDante instance representing the database 153 | :param model: Optional Pydantic model class for data validation and serialization 154 | """ 155 | self.name = name 156 | self.db = db 157 | self.model = model 158 | 159 | def __str__(self) -> str: 160 | """ 161 | Return a string representation of the BaseCollection instance. 162 | 163 | :return: String representation of the instance 164 | """ 165 | return f'<{self.__class__.__name__}("{self.db.db_name}/{self.name}")>' 166 | 167 | def _to_json(self, data: dict | TModel) -> str: 168 | """ 169 | Internal method to serialize data to JSON before saving. 170 | 171 | :param data: Data or Pydantic object to serialize 172 | :return: JSON-serialized data 173 | """ 174 | if self.model and isinstance(data, BaseModel): 175 | return data.model_dump_json() 176 | else: 177 | return json.dumps(data, cls=DanteEncoder) 178 | 179 | def _from_json(self, json_text: str) -> dict | TModel: 180 | """ 181 | Internal method to parse JSON data after loading. 182 | 183 | :param json_text: Raw JSON data to parse 184 | :return: Deserialized data as dictionary or Pydantic model 185 | """ 186 | data = json.loads(json_text) 187 | return self.model(**data) if self.model and callable(self.model) else data 188 | 189 | def _build_query(_self, _limit: int | None, /, **kwargs: Any) -> tuple[str, list]: 190 | """ 191 | Internal method to create an SQL WHERE/LIMIT clauses. 192 | 193 | Builds the query from key/value pairs with optional limit. 194 | 195 | :param _limit: Optional LIMIT clause 196 | :param kwargs: key/value pairs to search for 197 | :return: fragments of query to prepare, with corresponding values 198 | 199 | """ 200 | values = [] 201 | if kwargs: 202 | query_parts = [] 203 | for key, value in kwargs.items(): 204 | key = "$." + key.replace("__", ".") 205 | query_parts.append("data->>? = ?") 206 | values.extend([key, value]) 207 | query = " WHERE " + " AND ".join(query_parts) 208 | else: 209 | query = "" 210 | 211 | if _limit: 212 | query += " LIMIT ?" 213 | values.append(_limit) 214 | 215 | return query, values 216 | 217 | def _build_set_clause(_self, **kwargs: Any) -> tuple[str, list]: 218 | """ 219 | Internal method to create an SQL SET clause. 220 | 221 | Builds the SET clause from key/value pairs. 222 | 223 | :param kwargs: key/value pairs to set 224 | :return: fragments of query to prepare, with corresponding values 225 | """ 226 | clause_parts = [] 227 | values = [] 228 | for key, value in kwargs.items(): 229 | key = key.replace("__", ".") 230 | clause_parts.append("?, ?") 231 | values.extend(["$." + key, value]) 232 | clause = "SET data = json_set(data, " + ", ".join(clause_parts) + ")" 233 | 234 | return clause, values 235 | 236 | @abstractmethod 237 | def insert(self, data: dict | TModel): 238 | """ 239 | Insert data into the collection. 240 | 241 | :param data: Data to insert 242 | """ 243 | 244 | @abstractmethod 245 | def find_many( 246 | _self, 247 | _limit: int | None = None, 248 | /, 249 | **kwargs: Any, 250 | ): 251 | """ 252 | Find documents matching the query. 253 | 254 | :param _limit: Maximum number of documents to return 255 | :param kwargs: Fields to match in the documents 256 | :return: List of documents matching the query 257 | """ 258 | 259 | @abstractmethod 260 | def find_one(_self, **kwargs: Any): 261 | """ 262 | Find a single document matching the query. 263 | 264 | If multiple documents match the query, an arbitrary matching 265 | document is returned. 266 | 267 | :param kwargs: Fields to match in the document 268 | :return: Document matching the query, or None if not found 269 | """ 270 | 271 | @abstractmethod 272 | def update(_self, _data: dict | TModel, /, **kwargs: Any): 273 | """ 274 | Update documents matching the query. 275 | 276 | Note that the data must be a full object, not just the fields to update. 277 | 278 | :param _data: Data to update with (must be a full object) 279 | :param kwargs: Fields to match in the documents 280 | :return: Number of documents updated 281 | """ 282 | 283 | @abstractmethod 284 | def delete(_self, /, **kwargs: Any): 285 | """ 286 | Delete documents matching the query. 287 | 288 | :param kwargs: Fields to match in the documents 289 | :return: Number of documents deleted 290 | """ 291 | 292 | @abstractmethod 293 | def set(_self, _fields: dict[str, Any], **kwargs: Any): 294 | """ 295 | Update specific fields in documents matching the query. 296 | 297 | :param _fields: Fields to update 298 | :param kwargs: Fields to match in the documents 299 | :return: Number of documents updated 300 | """ 301 | 302 | @abstractmethod 303 | def clear(self): 304 | """ 305 | Delete all documents in the collection. 306 | 307 | :return: Number of documents deleted 308 | """ 309 | --------------------------------------------------------------------------------