etchdb 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- etchdb-0.1.0/.gitignore +69 -0
- etchdb-0.1.0/.python-version +1 -0
- etchdb-0.1.0/LICENSE +21 -0
- etchdb-0.1.0/Makefile +61 -0
- etchdb-0.1.0/PKG-INFO +168 -0
- etchdb-0.1.0/README.md +106 -0
- etchdb-0.1.0/docker-compose.yml +16 -0
- etchdb-0.1.0/pyproject.toml +76 -0
- etchdb-0.1.0/src/etchdb/__init__.py +9 -0
- etchdb-0.1.0/src/etchdb/adapter.py +56 -0
- etchdb-0.1.0/src/etchdb/aiosqlite/__init__.py +17 -0
- etchdb-0.1.0/src/etchdb/aiosqlite/adapter.py +148 -0
- etchdb-0.1.0/src/etchdb/asyncpg/__init__.py +17 -0
- etchdb-0.1.0/src/etchdb/asyncpg/adapter.py +106 -0
- etchdb-0.1.0/src/etchdb/db.py +179 -0
- etchdb-0.1.0/src/etchdb/py.typed +0 -0
- etchdb-0.1.0/src/etchdb/query.py +18 -0
- etchdb-0.1.0/src/etchdb/row.py +29 -0
- etchdb-0.1.0/src/etchdb/sql/__init__.py +209 -0
- etchdb-0.1.0/tests/__init__.py +0 -0
- etchdb-0.1.0/tests/_models.py +26 -0
- etchdb-0.1.0/tests/integration/__init__.py +0 -0
- etchdb-0.1.0/tests/integration/conftest.py +76 -0
- etchdb-0.1.0/tests/integration/test_facade.py +87 -0
- etchdb-0.1.0/tests/integration/test_readme_example.py +65 -0
- etchdb-0.1.0/tests/integration/test_typed_crud.py +163 -0
- etchdb-0.1.0/tests/unit/__init__.py +0 -0
- etchdb-0.1.0/tests/unit/test_adapters.py +27 -0
- etchdb-0.1.0/tests/unit/test_db_dispatch.py +29 -0
- etchdb-0.1.0/tests/unit/test_sql_emitter.py +382 -0
etchdb-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
|
|
27
|
+
# Unit test / coverage
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
.coverage.*
|
|
31
|
+
htmlcov/
|
|
32
|
+
.tox/
|
|
33
|
+
.cache
|
|
34
|
+
nosetests.xml
|
|
35
|
+
coverage.xml
|
|
36
|
+
*.cover
|
|
37
|
+
|
|
38
|
+
# Type checking
|
|
39
|
+
.mypy_cache/
|
|
40
|
+
.pyre/
|
|
41
|
+
.pytype/
|
|
42
|
+
|
|
43
|
+
# Linting
|
|
44
|
+
.ruff_cache/
|
|
45
|
+
|
|
46
|
+
# Virtual environments
|
|
47
|
+
.venv/
|
|
48
|
+
venv/
|
|
49
|
+
env/
|
|
50
|
+
ENV/
|
|
51
|
+
|
|
52
|
+
# IDE
|
|
53
|
+
.idea/
|
|
54
|
+
.vscode/
|
|
55
|
+
*.swp
|
|
56
|
+
*~
|
|
57
|
+
|
|
58
|
+
# OS
|
|
59
|
+
.DS_Store
|
|
60
|
+
Thumbs.db
|
|
61
|
+
|
|
62
|
+
# uv (library: consumers decide their own lockfile)
|
|
63
|
+
.uv/
|
|
64
|
+
uv.lock
|
|
65
|
+
|
|
66
|
+
# Local notes / scratch / handoffs (not part of public history)
|
|
67
|
+
scratch/
|
|
68
|
+
TODO.local.md
|
|
69
|
+
.local/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
etchdb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hannu Varjoranta
|
|
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.
|
etchdb-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.PHONY: help install test test-unit test-integration coverage db-up db-down lint typecheck fix build deploy clean
|
|
2
|
+
.DEFAULT_GOAL := help
|
|
3
|
+
|
|
4
|
+
help:
|
|
5
|
+
@echo "etchdb development targets"
|
|
6
|
+
@echo ""
|
|
7
|
+
@echo " make install install package + dev deps in a uv venv"
|
|
8
|
+
@echo " make test run the full test suite (postgres tests skip if DB is down)"
|
|
9
|
+
@echo " make test-unit run only the dialect-neutral unit tests"
|
|
10
|
+
@echo " make test-integration bring up postgres, then run integration tests"
|
|
11
|
+
@echo " make coverage run the test suite with line-coverage reporting"
|
|
12
|
+
@echo " make db-up start the postgres container on localhost:5532"
|
|
13
|
+
@echo " make db-down stop and remove the postgres container + volumes"
|
|
14
|
+
@echo " make lint check lint + formatting"
|
|
15
|
+
@echo " make typecheck run static type checking (ty)"
|
|
16
|
+
@echo " make fix auto-format + auto-fix lint"
|
|
17
|
+
@echo " make build build sdist + wheel into dist/"
|
|
18
|
+
@echo " make deploy build then upload to PyPI (needs UV_PUBLISH_TOKEN)"
|
|
19
|
+
@echo " make clean remove build artifacts"
|
|
20
|
+
|
|
21
|
+
install:
|
|
22
|
+
uv sync --extra dev --extra all
|
|
23
|
+
|
|
24
|
+
test:
|
|
25
|
+
uv run pytest
|
|
26
|
+
|
|
27
|
+
test-unit:
|
|
28
|
+
uv run pytest tests/unit -v
|
|
29
|
+
|
|
30
|
+
test-integration: db-up
|
|
31
|
+
uv run pytest tests/integration -v
|
|
32
|
+
|
|
33
|
+
coverage:
|
|
34
|
+
uv run pytest --cov=etchdb --cov-report=term-missing
|
|
35
|
+
|
|
36
|
+
db-up:
|
|
37
|
+
docker compose up -d --wait postgres
|
|
38
|
+
@echo "postgres ready on localhost:5532"
|
|
39
|
+
|
|
40
|
+
db-down:
|
|
41
|
+
docker compose down -v
|
|
42
|
+
|
|
43
|
+
lint:
|
|
44
|
+
uv run ruff check .
|
|
45
|
+
uv run ruff format --check .
|
|
46
|
+
|
|
47
|
+
typecheck:
|
|
48
|
+
uv run ty check src
|
|
49
|
+
|
|
50
|
+
fix:
|
|
51
|
+
uv run ruff format .
|
|
52
|
+
uv run ruff check --fix .
|
|
53
|
+
|
|
54
|
+
build: clean
|
|
55
|
+
uv build
|
|
56
|
+
|
|
57
|
+
deploy: build
|
|
58
|
+
uv publish
|
|
59
|
+
|
|
60
|
+
clean:
|
|
61
|
+
rm -rf dist/ build/ src/*.egg-info
|
etchdb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etchdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimal async DB layer for Python. Typed CRUD over Pydantic, raw SQL when you need it
|
|
5
|
+
Project-URL: Homepage, https://github.com/varjoranta/etchdb
|
|
6
|
+
Project-URL: Repository, https://github.com/varjoranta/etchdb
|
|
7
|
+
Project-URL: Issues, https://github.com/varjoranta/etchdb/issues
|
|
8
|
+
Author-email: Hannu Varjoranta <hannu@varjosoft.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Hannu Varjoranta
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: async,asyncpg,database,orm,postgresql,psycopg,pydantic,sqlite
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Framework :: AsyncIO
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
39
|
+
Classifier: Topic :: Database
|
|
40
|
+
Classifier: Topic :: Database :: Database Engines/Servers
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Classifier: Typing :: Typed
|
|
43
|
+
Requires-Python: >=3.12
|
|
44
|
+
Requires-Dist: pydantic>=2.0.0
|
|
45
|
+
Provides-Extra: all
|
|
46
|
+
Requires-Dist: aiosqlite>=0.19.0; extra == 'all'
|
|
47
|
+
Requires-Dist: asyncpg>=0.30.0; extra == 'all'
|
|
48
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'all'
|
|
49
|
+
Provides-Extra: asyncpg
|
|
50
|
+
Requires-Dist: asyncpg>=0.30.0; extra == 'asyncpg'
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
55
|
+
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: ty; extra == 'dev'
|
|
57
|
+
Provides-Extra: psycopg
|
|
58
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == 'psycopg'
|
|
59
|
+
Provides-Extra: sqlite
|
|
60
|
+
Requires-Dist: aiosqlite>=0.19.0; extra == 'sqlite'
|
|
61
|
+
Description-Content-Type: text/markdown
|
|
62
|
+
|
|
63
|
+
# etchdb
|
|
64
|
+
|
|
65
|
+
Minimal async DB layer for Python. Typed CRUD over Pydantic. Raw SQL when you need it.
|
|
66
|
+
|
|
67
|
+
## Status
|
|
68
|
+
|
|
69
|
+
Alpha. v0.1.0 on PyPI. Built in public from day one; expect tightening between alpha releases.
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from etchdb import DB, Row
|
|
75
|
+
|
|
76
|
+
class User(Row):
|
|
77
|
+
__table__ = "users"
|
|
78
|
+
id: int | None = None # leave unset and the DB allocates it (SERIAL / INTEGER PK)
|
|
79
|
+
name: str
|
|
80
|
+
email: str | None = None
|
|
81
|
+
|
|
82
|
+
# Connect (driver inferred from URL scheme)
|
|
83
|
+
db = await DB.from_url("postgresql+asyncpg://user@host/db")
|
|
84
|
+
|
|
85
|
+
# Typed CRUD
|
|
86
|
+
alice = await db.insert(User(name="Alice")) # alice.id is now populated by the DB
|
|
87
|
+
user = await db.get(User, id=alice.id) # one row or None
|
|
88
|
+
users = await db.query(User) # list of rows
|
|
89
|
+
await db.update(User(id=alice.id, name="Alice B")) # partial: email is preserved
|
|
90
|
+
await db.delete(alice)
|
|
91
|
+
|
|
92
|
+
# Typed-result raw SQL (covers most joins)
|
|
93
|
+
users = await db.fetch_models(User, """
|
|
94
|
+
SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id
|
|
95
|
+
WHERE o.created_at > $1
|
|
96
|
+
""", since)
|
|
97
|
+
|
|
98
|
+
# Untyped raw SQL (mirrors asyncpg)
|
|
99
|
+
rows = await db.fetch("SELECT count(*) FROM events WHERE site_id = $1", site_id)
|
|
100
|
+
val = await db.fetchval("SELECT count(*) FROM users")
|
|
101
|
+
await db.execute("UPDATE users SET active = false WHERE id = $1", uid)
|
|
102
|
+
|
|
103
|
+
# Transactions
|
|
104
|
+
async with db.transaction() as tx:
|
|
105
|
+
await tx.insert(User(name="Carol"))
|
|
106
|
+
await tx.execute("INSERT INTO audit_log (...) VALUES (...)")
|
|
107
|
+
|
|
108
|
+
# Inspect SQL before executing (etchdb's defining feature)
|
|
109
|
+
q = db.compose("get", User, id=1)
|
|
110
|
+
print(q.sql) # SELECT id, name, email FROM users WHERE id = $1
|
|
111
|
+
print(q.params) # [1]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`insert` only emits the columns you actually set, so an unset `id` lets the database allocate one (SERIAL or INTEGER PRIMARY KEY). `update` does the same: a column you didn't touch keeps its current value rather than being clobbered. An explicit `None` counts as set in both cases.
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
Drivers are optional extras. Install only what you use:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install etchdb[asyncpg] # asyncpg + Postgres
|
|
122
|
+
pip install etchdb[psycopg] # psycopg3 + Postgres
|
|
123
|
+
pip install etchdb[sqlite] # aiosqlite + SQLite
|
|
124
|
+
pip install etchdb[all] # everything
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The top-level `etchdb` namespace depends only on Pydantic. Driver subpackages import their driver eagerly with a clear error if it is not installed.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from etchdb import DB, Row # always safe
|
|
131
|
+
from etchdb.asyncpg import AsyncpgAdapter # requires asyncpg
|
|
132
|
+
|
|
133
|
+
# Bring your own pool
|
|
134
|
+
db = DB(AsyncpgAdapter.from_pool(my_pool))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Why
|
|
138
|
+
|
|
139
|
+
Most Python ORMs are heavy, opinionated, and leak at the seams when you reach for pgvector or PostGIS. Raw asyncpg works, but every project ends up writing the same Pydantic-bridge code. etchdb closes that gap without becoming a framework.
|
|
140
|
+
|
|
141
|
+
The design also targets AI-assisted development: predictable verbs, no metaclass magic, no implicit context vars, no lazy loading, every typed operation produces inspectable SQL. Code an LLM can write correctly on the first attempt.
|
|
142
|
+
|
|
143
|
+
## Goals
|
|
144
|
+
|
|
145
|
+
- Driver-agnostic (asyncpg or psycopg3, swap freely)
|
|
146
|
+
- Multi-dialect (Postgres primary, SQLite secondary, MySQL maybe)
|
|
147
|
+
- Async native, no sync wrappers
|
|
148
|
+
- Typed CRUD via Pydantic; raw SQL as first-class escape valve
|
|
149
|
+
- Inspectable SQL: every typed op exposes its `(sql, params)` without executing
|
|
150
|
+
|
|
151
|
+
## Non-goals
|
|
152
|
+
|
|
153
|
+
- Query builder beyond simple CRUD (use raw SQL for joins)
|
|
154
|
+
- Implicit relationships, lazy loading, eager loading
|
|
155
|
+
- Sync support
|
|
156
|
+
- A second canonical way to do anything
|
|
157
|
+
|
|
158
|
+
## Migrations
|
|
159
|
+
|
|
160
|
+
Out of scope for v0.1. A small forward-only, file-based migration helper (no autogenerate, no rollback, no DAG) is planned for a later release. etchdb owns no schema state today, so any external tool slots in fine in the meantime: Alembic if you also use SQLAlchemy, dbmate or sqitch if you don't, or a few `db.execute` calls in your bootstrap path.
|
|
161
|
+
|
|
162
|
+
## Built with AI assistance
|
|
163
|
+
|
|
164
|
+
Built with Claude Code as the primary development assistant. Design, code, and commits are reviewed and shipped by Hannu Varjoranta. Building in public, openly using AI tooling, is part of the project's premise.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT.
|
etchdb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# etchdb
|
|
2
|
+
|
|
3
|
+
Minimal async DB layer for Python. Typed CRUD over Pydantic. Raw SQL when you need it.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Alpha. v0.1.0 on PyPI. Built in public from day one; expect tightening between alpha releases.
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from etchdb import DB, Row
|
|
13
|
+
|
|
14
|
+
class User(Row):
|
|
15
|
+
__table__ = "users"
|
|
16
|
+
id: int | None = None # leave unset and the DB allocates it (SERIAL / INTEGER PK)
|
|
17
|
+
name: str
|
|
18
|
+
email: str | None = None
|
|
19
|
+
|
|
20
|
+
# Connect (driver inferred from URL scheme)
|
|
21
|
+
db = await DB.from_url("postgresql+asyncpg://user@host/db")
|
|
22
|
+
|
|
23
|
+
# Typed CRUD
|
|
24
|
+
alice = await db.insert(User(name="Alice")) # alice.id is now populated by the DB
|
|
25
|
+
user = await db.get(User, id=alice.id) # one row or None
|
|
26
|
+
users = await db.query(User) # list of rows
|
|
27
|
+
await db.update(User(id=alice.id, name="Alice B")) # partial: email is preserved
|
|
28
|
+
await db.delete(alice)
|
|
29
|
+
|
|
30
|
+
# Typed-result raw SQL (covers most joins)
|
|
31
|
+
users = await db.fetch_models(User, """
|
|
32
|
+
SELECT u.* FROM users u JOIN orders o ON o.user_id = u.id
|
|
33
|
+
WHERE o.created_at > $1
|
|
34
|
+
""", since)
|
|
35
|
+
|
|
36
|
+
# Untyped raw SQL (mirrors asyncpg)
|
|
37
|
+
rows = await db.fetch("SELECT count(*) FROM events WHERE site_id = $1", site_id)
|
|
38
|
+
val = await db.fetchval("SELECT count(*) FROM users")
|
|
39
|
+
await db.execute("UPDATE users SET active = false WHERE id = $1", uid)
|
|
40
|
+
|
|
41
|
+
# Transactions
|
|
42
|
+
async with db.transaction() as tx:
|
|
43
|
+
await tx.insert(User(name="Carol"))
|
|
44
|
+
await tx.execute("INSERT INTO audit_log (...) VALUES (...)")
|
|
45
|
+
|
|
46
|
+
# Inspect SQL before executing (etchdb's defining feature)
|
|
47
|
+
q = db.compose("get", User, id=1)
|
|
48
|
+
print(q.sql) # SELECT id, name, email FROM users WHERE id = $1
|
|
49
|
+
print(q.params) # [1]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`insert` only emits the columns you actually set, so an unset `id` lets the database allocate one (SERIAL or INTEGER PRIMARY KEY). `update` does the same: a column you didn't touch keeps its current value rather than being clobbered. An explicit `None` counts as set in both cases.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
Drivers are optional extras. Install only what you use:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install etchdb[asyncpg] # asyncpg + Postgres
|
|
60
|
+
pip install etchdb[psycopg] # psycopg3 + Postgres
|
|
61
|
+
pip install etchdb[sqlite] # aiosqlite + SQLite
|
|
62
|
+
pip install etchdb[all] # everything
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The top-level `etchdb` namespace depends only on Pydantic. Driver subpackages import their driver eagerly with a clear error if it is not installed.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from etchdb import DB, Row # always safe
|
|
69
|
+
from etchdb.asyncpg import AsyncpgAdapter # requires asyncpg
|
|
70
|
+
|
|
71
|
+
# Bring your own pool
|
|
72
|
+
db = DB(AsyncpgAdapter.from_pool(my_pool))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Why
|
|
76
|
+
|
|
77
|
+
Most Python ORMs are heavy, opinionated, and leak at the seams when you reach for pgvector or PostGIS. Raw asyncpg works, but every project ends up writing the same Pydantic-bridge code. etchdb closes that gap without becoming a framework.
|
|
78
|
+
|
|
79
|
+
The design also targets AI-assisted development: predictable verbs, no metaclass magic, no implicit context vars, no lazy loading, every typed operation produces inspectable SQL. Code an LLM can write correctly on the first attempt.
|
|
80
|
+
|
|
81
|
+
## Goals
|
|
82
|
+
|
|
83
|
+
- Driver-agnostic (asyncpg or psycopg3, swap freely)
|
|
84
|
+
- Multi-dialect (Postgres primary, SQLite secondary, MySQL maybe)
|
|
85
|
+
- Async native, no sync wrappers
|
|
86
|
+
- Typed CRUD via Pydantic; raw SQL as first-class escape valve
|
|
87
|
+
- Inspectable SQL: every typed op exposes its `(sql, params)` without executing
|
|
88
|
+
|
|
89
|
+
## Non-goals
|
|
90
|
+
|
|
91
|
+
- Query builder beyond simple CRUD (use raw SQL for joins)
|
|
92
|
+
- Implicit relationships, lazy loading, eager loading
|
|
93
|
+
- Sync support
|
|
94
|
+
- A second canonical way to do anything
|
|
95
|
+
|
|
96
|
+
## Migrations
|
|
97
|
+
|
|
98
|
+
Out of scope for v0.1. A small forward-only, file-based migration helper (no autogenerate, no rollback, no DAG) is planned for a later release. etchdb owns no schema state today, so any external tool slots in fine in the meantime: Alembic if you also use SQLAlchemy, dbmate or sqitch if you don't, or a few `db.execute` calls in your bootstrap path.
|
|
99
|
+
|
|
100
|
+
## Built with AI assistance
|
|
101
|
+
|
|
102
|
+
Built with Claude Code as the primary development assistant. Design, code, and commits are reviewed and shipped by Hannu Varjoranta. Building in public, openly using AI tooling, is part of the project's premise.
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:18-trixie
|
|
4
|
+
container_name: etchdb-postgres-test
|
|
5
|
+
environment:
|
|
6
|
+
POSTGRES_DB: etchdb_test
|
|
7
|
+
POSTGRES_USER: etchdb
|
|
8
|
+
POSTGRES_PASSWORD: etchdb-test-password
|
|
9
|
+
ports:
|
|
10
|
+
- "5532:5432"
|
|
11
|
+
healthcheck:
|
|
12
|
+
test: ["CMD-SHELL", "pg_isready -U etchdb -d etchdb_test"]
|
|
13
|
+
interval: 1s
|
|
14
|
+
timeout: 1s
|
|
15
|
+
retries: 30
|
|
16
|
+
restart: unless-stopped
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "etchdb"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Minimal async DB layer for Python. Typed CRUD over Pydantic, raw SQL when you need it"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Hannu Varjoranta", email = "hannu@varjosoft.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = [
|
|
12
|
+
"postgresql",
|
|
13
|
+
"asyncpg",
|
|
14
|
+
"psycopg",
|
|
15
|
+
"pydantic",
|
|
16
|
+
"async",
|
|
17
|
+
"database",
|
|
18
|
+
"sqlite",
|
|
19
|
+
"orm",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 3 - Alpha",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Database",
|
|
28
|
+
"Topic :: Database :: Database Engines/Servers",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Framework :: AsyncIO",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = [
|
|
35
|
+
"pydantic>=2.0.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
asyncpg = ["asyncpg>=0.30.0"]
|
|
40
|
+
psycopg = ["psycopg[binary]>=3.2"]
|
|
41
|
+
sqlite = ["aiosqlite>=0.19.0"]
|
|
42
|
+
all = [
|
|
43
|
+
"asyncpg>=0.30.0",
|
|
44
|
+
"psycopg[binary]>=3.2",
|
|
45
|
+
"aiosqlite>=0.19.0",
|
|
46
|
+
]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=8.0.0",
|
|
49
|
+
"pytest-asyncio>=0.21.0",
|
|
50
|
+
"pytest-cov>=4.0.0",
|
|
51
|
+
"ruff>=0.5.0",
|
|
52
|
+
"ty",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[project.urls]
|
|
56
|
+
Homepage = "https://github.com/varjoranta/etchdb"
|
|
57
|
+
Repository = "https://github.com/varjoranta/etchdb"
|
|
58
|
+
Issues = "https://github.com/varjoranta/etchdb/issues"
|
|
59
|
+
|
|
60
|
+
[build-system]
|
|
61
|
+
requires = ["hatchling"]
|
|
62
|
+
build-backend = "hatchling.build"
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.wheel]
|
|
65
|
+
packages = ["src/etchdb"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 100
|
|
69
|
+
target-version = "py312"
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
|
|
73
|
+
|
|
74
|
+
[tool.pytest.ini_options]
|
|
75
|
+
asyncio_mode = "auto"
|
|
76
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Abstract DB adapter; the boundary DB sits on top of."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from contextlib import AbstractAsyncContextManager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AdapterBase(ABC):
|
|
9
|
+
"""Abstract DB adapter implemented by each driver.
|
|
10
|
+
|
|
11
|
+
The four raw-SQL methods mirror asyncpg's vocabulary:
|
|
12
|
+
`execute / fetch / fetchrow / fetchval`. All take positional
|
|
13
|
+
`*params` bound through the driver's parameterised query API.
|
|
14
|
+
|
|
15
|
+
`placeholder(i)` converts a 0-indexed parameter position to the
|
|
16
|
+
driver's placeholder syntax. Postgres returns `$1, $2, ...`; SQLite
|
|
17
|
+
returns `?`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def placeholder(i: int) -> str:
|
|
23
|
+
"""Return the placeholder for the i-th parameter (0-indexed)."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def execute(self, sql: str, *params: Any) -> Any: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def fetch(self, sql: str, *params: Any) -> list[dict[str, Any]]: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def fetchrow(self, sql: str, *params: Any) -> dict[str, Any] | None: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def fetchval(self, sql: str, *params: Any) -> Any: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def transaction(self) -> AbstractAsyncContextManager["AdapterBase"]:
|
|
40
|
+
"""Return an async context manager yielding a transaction-scoped adapter.
|
|
41
|
+
|
|
42
|
+
Inside the `async with` block, all calls on the yielded adapter run
|
|
43
|
+
on the same connection within a single transaction. The transaction
|
|
44
|
+
commits on a clean exit and rolls back on any exception.
|
|
45
|
+
"""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def close(self) -> None:
|
|
50
|
+
"""Release resources owned by this adapter.
|
|
51
|
+
|
|
52
|
+
For pool-owning adapters created via `from_url`, this closes the
|
|
53
|
+
pool. For adapters wrapping an externally-managed pool (`from_pool`),
|
|
54
|
+
this is a no-op; the caller owns the pool's lifecycle.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""aiosqlite adapter for etchdb.
|
|
2
|
+
|
|
3
|
+
Importing this subpackage requires aiosqlite to be installed. The
|
|
4
|
+
top-level `etchdb` namespace does NOT depend on aiosqlite; only this
|
|
5
|
+
subpackage does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import aiosqlite as _aiosqlite # noqa: F401
|
|
10
|
+
except ImportError as e:
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"etchdb.aiosqlite requires the aiosqlite package. Install with: pip install etchdb[sqlite]"
|
|
13
|
+
) from e
|
|
14
|
+
|
|
15
|
+
from etchdb.aiosqlite.adapter import AiosqliteAdapter
|
|
16
|
+
|
|
17
|
+
__all__ = ["AiosqliteAdapter"]
|